#!/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. 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 (already current)" else skip "starship config (yours differs — kept; 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 /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" </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 ""