8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp and 17 the remote desktop move to scripts/setup/setup-sidecars.sh, which nothing invokes — running it is a deliberate act. They are what the optional, sidecar-backed features need on the host, not what the app needs to serve itself. 11 Neovim, 12 the shell extras and 14 the npm globals are gone entirely. The host provisioning already installs node, npm, pm2, Claude Code, Neovim and the shell, and two installers racing for the same binaries is worse than one. That makes node, npm, pm2 and the agent CLIs prerequisites of this script rather than products of it, so the verification block still checks claude and pm2 — section 19 warns and skips rather than failing when pm2 is absent, which would otherwise finish "successfully" with nothing listening. eza is the one casualty: the provisioning installs lazygit, starship, oh-my-zsh and nvim, but not that. Section numbers keep their gaps so the two files read against each other. One line survives from the removed section 14 — the ~/.local/bin PATH export, which section 19's `has pm2` and the agent's claude lookup both still depend on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
907 lines
33 KiB
Bash
Executable File
907 lines
33 KiB
Bash
Executable File
#!/bin/bash
|
|
# Officer — host dependency setup
|
|
# Run once on a fresh Ubuntu/Debian host before launching the server.
|
|
#
|
|
# Usage:
|
|
# bash scripts/setup/setup.sh # full server install
|
|
# OFFICER_PROFILE=light bash scripts/setup/setup.sh # light install
|
|
#
|
|
# PROFILES
|
|
# full Everything: the self-hosted estate, the remote desktop, the music/audio stack, the shell
|
|
# and editor tooling. What a dedicated Officer server wants.
|
|
# light The same process set as the macOS build — the file browser, the terminal, and
|
|
# Claude/opencode chat — on a Linux host. Installs only what those need: node, bun, ffmpeg,
|
|
# Postgres, pm2 and the two agent CLIs, then starts ecosystem.light.config.cjs.
|
|
#
|
|
# Skipped by `light`: archive extras, the sudoers entry and auto-suspend disabling, and Go.
|
|
# Of the Docker services only Postgres is brought up.
|
|
#
|
|
# The app itself is identical — every API route stays mounted, so the features whose sidecars
|
|
# are not running report themselves unavailable rather than disappearing. A profile changes
|
|
# which processes start, not which code ships.
|
|
#
|
|
# NOT INSTALLED HERE — and the gaps in the section numbers are where these used to be
|
|
# Moved to scripts/setup/setup-sidecars.sh, which nothing below invokes; run it deliberately, and
|
|
# only after this script: 8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp, 17 remote desktop.
|
|
#
|
|
# Removed outright, because the host provisioning already installs them and two installers racing
|
|
# for the same binaries is worse than one: 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit),
|
|
# 14 npm globals (the ~/.local npm prefix, Claude Code, pm2).
|
|
#
|
|
# That makes node, npm, pm2 and the agent CLIs PREREQUISITES of this script rather than products of
|
|
# it. Section 19 warns and skips rather than failing if pm2 is absent, so a host that never ran the
|
|
# provisioning will finish "successfully" with nothing listening — check the verification block.
|
|
|
|
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)"; }
|
|
omit() { echo -e " - $1 (skipped: light profile)"; }
|
|
|
|
has() { command -v "$1" &>/dev/null; }
|
|
|
|
OFFICER_PROFILE="${OFFICER_PROFILE:-full}"
|
|
case "$OFFICER_PROFILE" in
|
|
full|light) ;;
|
|
*) echo "Unknown OFFICER_PROFILE '$OFFICER_PROFILE' — expected 'full' or 'light'." >&2; exit 2 ;;
|
|
esac
|
|
is_light() { [ "$OFFICER_PROFILE" = "light" ]; }
|
|
|
|
# Which pm2 process list this install starts and verifies. ecosystem.light.config.cjs derives its apps
|
|
# from ecosystem.config.cjs, so the two cannot disagree about how a process is launched.
|
|
if is_light; then ECOSYSTEM_FILE="ecosystem.light.config.cjs"; else ECOSYSTEM_FILE="ecosystem.config.cjs"; fi
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# ../.. — this lives in scripts/setup/, so the repo root is two levels up, not one. Nothing here fails
|
|
# loudly if that is wrong: PROJECT_DIR is where .env is written, where `bun install` and `db:push` run and
|
|
# where pm2 is pointed, so an off-by-one level silently sets up scripts/ instead of the repo.
|
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
|
# 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, in setup-sidecars.sh)
|
|
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
|
|
|
|
# uidmap — newuidmap/newgidmap, needed for a member's own rootless Docker.
|
|
#
|
|
# Rootless containers map subordinate uid ranges, and those two setuid helpers are the only way to do it
|
|
# unprivileged. Without them `dockerd-rootless-setuptool.sh` fails at the first step. Core rather than a
|
|
# profile extra for the same reason acl is: the alternative to a member having their own daemon is adding
|
|
# them to the `docker` group, which is root on the host — see src/servers/os-user-docker.ts.
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s uidmap &>/dev/null 2>&1; then skip "uidmap"; else CORE_PKGS+=(uidmap); fi
|
|
if dpkg -s dbus-user-session &>/dev/null 2>&1; then skip "dbus-user-session"; else CORE_PKGS+=(dbus-user-session); fi
|
|
;;
|
|
pacman)
|
|
if has newuidmap; then skip "uidmap (shadow)"; else CORE_PKGS+=(shadow); fi
|
|
;;
|
|
esac
|
|
|
|
# acl — setfacl/getfacl, needed by per-user Linux accounts.
|
|
#
|
|
# A member's home is 700 and owned by them, which is right for a shell and locks the platform out of the
|
|
# file browser. Named ACL entries are what let both act on the same files without opening the home to every
|
|
# account on the box; mode bits cannot express it in both directions. Core rather than a profile extra
|
|
# because the alternative is an account that provisions and then cannot list its own home.
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s acl &>/dev/null 2>&1; then skip "acl"; else CORE_PKGS+=(acl); fi
|
|
;;
|
|
pacman|dnf|yum)
|
|
if has setfacl; then skip "acl"; else CORE_PKGS+=(acl); 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) ──"
|
|
|
|
if is_light; then
|
|
omit "7z, unrar"
|
|
else
|
|
|
|
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
|
|
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) ──"
|
|
|
|
# Both of these are server decisions. A passwordless sudoers entry is a security posture a small
|
|
# install should opt into deliberately, and a laptop-shaped host wants to keep suspending — the macOS
|
|
# build does neither, so `light` does neither.
|
|
if is_light; then
|
|
omit "sudoers entry, auto-suspend disabling"
|
|
else
|
|
|
|
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
|
|
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
|
|
|
|
# ─── 6b. Starship prompt ──────────────────────────────────────────────────────
|
|
#
|
|
# Outside the light-profile skip below, unlike the rest of the terminal tooling. The light profile exists to
|
|
# serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is also
|
|
# what every member gets when per-user Linux accounts are on. `src/servers/shell-skel/zshrc` deploys this same
|
|
# prompt to every account, so leaving starship out of light meant every member's shell fell back to the plain
|
|
# one on exactly the installs most likely to have members.
|
|
#
|
|
# One static binary and one config file, which is why it survived the cull that removed the rest of the
|
|
# terminal tooling: oh-my-zsh, eza and lazygit are host comforts the provisioning installs, and the shell
|
|
# template treats each as optional. Starship it does not — the prompt would visibly degrade.
|
|
echo ""
|
|
echo "── Prompt (starship) ──"
|
|
|
|
# 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. 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/setup/starship.toml ~/.config/ to take this one)"
|
|
fi
|
|
|
|
|
|
# Go is a host comfort rather than anything the app needs to serve a file browser, a terminal and a
|
|
# chat, so `light` skips it. It is the only section left in this block — 8-13 were removed or moved.
|
|
if is_light; then
|
|
echo ""
|
|
omit "Go"
|
|
else
|
|
|
|
# ─── 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, 9 PulseAudio, 10 cliamp and 13 yt-dlp are in setup-sidecars.sh.
|
|
# 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit) and 14 npm globals are gone entirely — the host
|
|
# provisioning owns node, npm, pm2, Claude Code, Neovim and the shell, and this script duplicating
|
|
# them meant two installers racing for the same binaries.
|
|
|
|
fi # end of the light-profile skip, which is now section 7 alone
|
|
|
|
# Kept from the removed section 14: nothing here installs into ~/.local/bin any more, but section 19
|
|
# still asks `has pm2` and the agent still resolves `claude` off PATH. A host that installed either
|
|
# user-locally would otherwise look like it has neither.
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
|
|
# ─── 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. Postgres is the only one of the five the light
|
|
# profile needs — it is the platform's only database. NPM, Mailhog, Redis and SearXNG all serve parts
|
|
# of the estate a light install is not running.
|
|
echo " Setting up Docker Compose services..."
|
|
if is_light; then
|
|
DOCKER_OUTPUT=$(SETUP_DOCKER_SERVICES=2 bash "$SCRIPT_DIR/setup-dockers.sh")
|
|
else
|
|
DOCKER_OUTPUT=$(bash "$SCRIPT_DIR/setup-dockers.sh")
|
|
fi
|
|
|
|
# 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}"
|
|
|
|
# A light install is reached at localhost on the machine running it, so there is exactly one right
|
|
# answer and no reason to make someone produce it. Plain HTTP is fine there: browsers treat
|
|
# http://localhost as a secure context, so passkeys, microphone capture and the clipboard all work
|
|
# without TLS. That stops being true over the LAN — http://192.168.x.x is NOT a secure context and
|
|
# those APIs fail in browser-specific ways — so reaching a light install from another device means
|
|
# putting an HTTPS proxy in front of it.
|
|
if is_light; then
|
|
echo -n " PUBLIC_URL [http://localhost:$ENV_PORT]: "
|
|
read -r ENV_PUBLIC_URL
|
|
ENV_PUBLIC_URL="${ENV_PUBLIC_URL:-http://localhost:$ENV_PORT}"
|
|
else
|
|
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
|
|
fi
|
|
|
|
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 is in setup-sidecars.sh.
|
|
|
|
# ─── 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-agent.
|
|
if ! has pm2 || [ ! -f "$PROJECT_DIR/$ECOSYSTEM_FILE" ]; then
|
|
warn "Skipping service start (pm2 or $ECOSYSTEM_FILE 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 ($ECOSYSTEM_FILE)..."
|
|
if (cd "$PROJECT_DIR" && pm2 startOrRestart "$ECOSYSTEM_FILE"); 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:"
|
|
# Only what this script still installs is checked. Reporting Go as NOT FOUND on a light install that
|
|
# deliberately skipped it makes a clean run look broken; so does checking for nvim, lazygit and eza,
|
|
# which this script no longer owns at all.
|
|
if ! is_light; then
|
|
check go
|
|
fi
|
|
check starship
|
|
check zsh
|
|
check rg
|
|
check fd
|
|
check jq
|
|
check htop
|
|
check tmux
|
|
check tree
|
|
check btop
|
|
check sqlite3
|
|
|
|
# Neither of these is installed here any more — they come from the host provisioning. They are still
|
|
# checked because section 19 and every chat turn depend on them, and "NOT FOUND" here is the only
|
|
# warning you get before the services silently do not start.
|
|
echo ""
|
|
echo "AI agents (from host provisioning):"
|
|
check claude
|
|
|
|
echo ""
|
|
echo "Process manager (from host provisioning):"
|
|
check pm2
|
|
|
|
echo ""
|
|
echo "Optional:"
|
|
check unzip
|
|
check 7z
|
|
check unrar
|
|
check pgrep
|
|
check fuser
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════"
|
|
echo " Setup complete!"
|
|
echo "═══════════════════════════════════════════"
|
|
# Services actually running is a better signal than the binaries being present. The list comes from the
|
|
# ecosystem this install started, so it cannot drift as sidecars are added.
|
|
#
|
|
# Read with node rather than grepped: ecosystem.light.config.cjs derives its apps from the full file
|
|
# and has no literal `name:` keys to match, so a grep would silently verify nothing. Loading it also
|
|
# exercises its own consistency checks, which is worth doing here.
|
|
if has pm2 && [ -f "$PROJECT_DIR/$ECOSYSTEM_FILE" ]; then
|
|
echo ""
|
|
echo "Services:"
|
|
ECOSYSTEM_APPS=$(node -e "require('$PROJECT_DIR/$ECOSYSTEM_FILE').apps.forEach(a=>console.log(a.name))" 2>/dev/null) \
|
|
|| fail "$ECOSYSTEM_FILE could not be loaded — run: node -e \"require('./$ECOSYSTEM_FILE')\" to see why"
|
|
for app in $ECOSYSTEM_APPS; 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 " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
|
|
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
|
|
echo " • node, npm, pm2 and the agent CLIs come from the host provisioning, not from here"
|
|
echo " • Rust, PulseAudio, cliamp, yt-dlp and the remote desktop are NOT installed by this script:"
|
|
echo " run 'bash scripts/setup/setup-sidecars.sh' if you want them"
|
|
echo ""
|