Files
platform/scripts/setup/setup.sh
T
pastilhasandClaude Opus 5 9c353f5f0d move host setup into scripts/setup/
scripts/ was holding two unrelated kinds of thing: install-this-machine, and run-this-occasionally. The
eight installers now live in scripts/setup/; what stays at the top level is the build steps (gen-index,
prebuild, build/) and the two maintenance scripts (reindex-music, rebuild-soulseek-tree).

The move is not just a rename. Three of these derive the repo root from their own location:

  setup.sh:51            PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
  setup_mac_light.sh:51  same
  cleanup-desktop.sh:134 ENV_FILE="$(dirname "$0")/../.env"

Left alone, all three would now resolve to scripts/ — and nothing downstream complains. PROJECT_DIR is
where .env is written, where `bun install`, `gen:index` and `db:push` run, and what pm2 is pointed at, so
a fresh install would have quietly provisioned scripts/ and reported success. cleanup-desktop.sh fails
the other way: it would find no .env, print "No .env — skipping", and leave the real VNC_PASSWORD in the
real file. All three are now `../..` with a comment saying why the level matters.

provision-user-dirs.ts imports data-path.ts relatively; that one tsgo caught.

Also disambiguated `setup.sh` where it had become two files. app-store/templates/<name>/setup.sh is a
per-sidecar installer with its own contract, and preflight.ts + docs/sidecar-app-store.md discussed both
in the same paragraph. The host one is now spelled with its full path at those sites.

Verified: bash -n on all six shell scripts, tsgo clean, os-user tests pass, both derivations resolve to
the repo root, starship.toml still resolves from os-user-shell.ts, and provision-user-dirs.ts runs under
DRY_RUN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 04:02:19 +00:00

1197 lines
44 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, Go, Rust,
# PulseAudio, cliamp, Neovim, the shell extras (oh-my-zsh/eza/lazygit), yt-dlp, and
# the remote desktop. 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.
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)
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. oh-my-zsh, eza and lazygit stay in section 12, where `light` skips
# them: those are host comforts, and the shell template treats each as optional.
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 — the nvim step below already gets this right by guarding on the config's
# existence, so this was just inconsistent. Converge when there is nothing to lose, keep what the user
# wrote when there is.
mkdir -p "$HOME/.config"
STARSHIP_DEST="$HOME/.config/starship.toml"
if [ ! -f "$STARSHIP_DEST" ]; then
cp "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"
ok "starship config deployed"
elif cmp -s "$SCRIPT_DIR/starship.toml" "$STARSHIP_DEST"; then
skip "starship config"
else
warn "starship config kept — yours differs (cp scripts/setup/starship.toml ~/.config/ to take this one)"
fi
# Sections 7-13 are one block because `light` skips all of them. Go and PulseAudio exist to build and
# feed cliamp; Rust has no consumer left in the tree; Neovim, the shell tooling and yt-dlp are host
# comforts and capability dependencies rather than anything the app needs to serve a file browser, a
# terminal and a chat.
if is_light; then
echo ""
omit "Go, Rust, PulseAudio, cliamp, Neovim, shell extras (oh-my-zsh/eza/lazygit), yt-dlp"
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 ──────────────────────────────────────────────────────────────────
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 (starship is section 6b, outside the light skip) ─────
echo ""
echo "── Terminal tools (oh-my-zsh, eza, lazygit) ──"
# 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
fi # end of the light-profile skip for sections 7-13
# ─── 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 | bash # bash, not sh: a piped script ignores its shebang and install.sh is bash
if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi
fi
# No /usr/local/bin/claude symlink. That existed because the sidecar hardcoded that path, which in
# turn came from the bwrap-sandboxed architecture — the jail ro-bound /usr and could not see the
# installer's real target in ~/.local/bin. The sandbox is gone and claude-manager.ts now resolves the
# CLI itself: $CLAUDE_BIN, then PATH, then ~/.local/bin/claude, /usr/local/bin/claude and
# /opt/homebrew/bin/claude. The installer above puts it in ~/.local/bin, which is both on PATH and the
# first candidate, so the symlink was satisfying a requirement that no longer exists — at the cost of
# a sudo-owned link into /usr/local/bin, a directory macOS does not even ship.
# 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. 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 (Ubuntu Desktop + VNC) ───────────────────────────────
echo ""
echo "── Remote Desktop (Ubuntu Desktop + VNC) ──"
# No "already installed" guard here on purpose. This used to skip on `dpkg -s ubuntu-desktop`, which
# treats one package being present as proof the whole remote desktop is configured — and those are very
# different things. A host can have ubuntu-desktop and still be missing every part that makes the mirror
# work: GDM auto-login, the forced Xorg session, the captured EDID and its kernel command line, the
# login-time mode setter. That was not hypothetical; it was this machine on 2026-08-02, where the guard
# reported "skip" while five of setup-desktop.sh's steps had never run and /desktop could not survive a
# reboot. setup-desktop.sh is idempotent throughout — every step either no-ops or is individually
# guarded — so letting it run each time converges a partially configured host instead of trusting a
# proxy for state it never actually checked.
# The light profile does not run officer-vnc, so there is nothing to mirror. This is the single most
# expensive section — it pulls the whole ubuntu-desktop meta-package — and the one most clearly outside
# "file browser, terminal, chat".
if is_light; then
omit "remote desktop (ubuntu-desktop, GDM, x11vnc, Brave)"
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-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 the ones the light profile actually installs are checked under it — reporting Go and cliamp as
# NOT FOUND on an install that deliberately skipped them makes a clean run look broken.
if ! is_light; then
check go
check rustc
check cargo
check nvim
check starship
check lazygit
check eza
fi
check zsh
check rg
check fd
check jq
check htop
check tmux
check tree
check btop
check sqlite3
if ! is_light; then
echo ""
echo "Audio (cliamp):"
check pulseaudio
check parec
check pactl
check cliamp
fi
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
if ! is_light; then check yt-dlp; fi
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 " • 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 ""