put the superseded setup scripts in setup-old, and repair what the move broke

scripts/setup/ is now what the new installer is being built in — machine-setup/
for the box, officer-setup.sh for the platform on top — and everything being
replaced moved to scripts/setup-old/. It still works and is still what to run.

Three things the move broke, and what each needed:

  starship.toml is not an old-setup artifact. os-user-shell.ts reads it at
  RUNTIME to seed a member's ~/.config/starship.toml when their Linux account is
  provisioned, and line 125 reads it inside a try whose catch returns
  "could not read the shell templates" — so account provisioning would have
  failed outright, not degraded. Moved back to scripts/setup/, which is where it
  belongs anyway (one file, both audiences) and which leaves the code correct
  with no edit.

  package.json's `setup` script pointed at a path that no longer exists. It now
  points at officer-setup.sh, where the installer is going, rather than at
  setup-old/ which is temporary.

  officer-setup.sh was created empty. An empty script exits 0, so `bun setup`
  would have reported success while doing nothing — worse than the broken path
  it replaced. It now explains that it is not written yet and exits 1, naming
  the setup-old script to run meanwhile.

Also brought .tmux.conf and ufw-docker-rules.conf in beside machine-setup.sh,
which reads both from SCRIPT_DIR and had been silently skipping them since the
script was vendored. ssh-keys.zip deliberately stays out: it is key material,
and *.zip is ignored.

Comments in os-user-claude.ts, app-store/preflight.ts and two docs still name the
old scripts/setup/setup.sh path. Left alone on purpose — repointing them at
setup-old/ only to repoint them again when officer-setup.sh lands is churn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 17:07:28 +00:00
co-authored by Claude Opus 5
parent 44141faf0a
commit 34bb8fc22a
10 changed files with 164 additions and 1 deletions
+906
View File
@@ -0,0 +1,906 @@
#!/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 ""