#!/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 /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:" # 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 ""