A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no completion, no colour. "Their own account" should not mean a worse terminal than the owner's. src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather than copied: setup.sh already deploys it for the owner, so one file serves both audiences and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies it to accounts that already exist — no delete-and-recreate. The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each used only if present, and every path is $HOME-relative — the owner's own .zshrc has three absolute /home/pastilhas paths in it, which is exactly what a template must not inherit. Without starship it falls back to a zsh prompt showing the same information, because a shell that opens with a broken prompt reads as a broken machine. Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and never written, so there is somewhere to put your own config that no future template can reach. Three fixes found by running it: - install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came out root:root — readable but not writable by its owner, which would have surfaced weeks later as one tool mysteriously failing. The parent is now created explicitly. - useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was launched from. A member's shell depended on how the server happened to be started. Now chosen from what is installed: zsh, else bash. - the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd shell via sh -c, so the login shell in /etc/passwd is the one they get. starship moves out of the light-profile skip. 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 what every member gets. Leaving starship out meant the fallback prompt on exactly the installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only. Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1178 lines
42 KiB
Bash
Executable File
1178 lines
42 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.sh # full server install
|
|
# OFFICER_PROFILE=light bash scripts/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)"
|
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
|
|
|
# Resolve the real user's home even when running under sudo
|
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
|
REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
|
else
|
|
REAL_HOME="$HOME"
|
|
fi
|
|
|
|
# ─── detect package manager ────────────────────────────────────────────────────
|
|
if has apt; then
|
|
PM=apt
|
|
elif has pacman; then
|
|
PM=pacman
|
|
elif has brew; then
|
|
PM=brew
|
|
else
|
|
fail "No supported package manager found (apt, pacman, brew)"
|
|
exit 1
|
|
fi
|
|
|
|
install_pkg() {
|
|
case $PM in
|
|
apt) sudo apt install -y "$@" ;;
|
|
pacman) sudo pacman -S --noconfirm "$@" ;;
|
|
brew) brew install "$@" ;;
|
|
esac
|
|
}
|
|
|
|
echo ""
|
|
echo "═══════════════════════════════════════════"
|
|
echo " Officer — dependency setup ($PM)"
|
|
echo "═══════════════════════════════════════════"
|
|
|
|
# ─── 1. core system packages ───────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Core system packages ──"
|
|
|
|
CORE_PKGS=()
|
|
|
|
# git
|
|
if has git; then skip "git"; else CORE_PKGS+=(git); fi
|
|
|
|
# zip / unzip
|
|
if has zip; then skip "zip"; else CORE_PKGS+=(zip); fi
|
|
if has unzip; then skip "unzip"; else CORE_PKGS+=(unzip); fi
|
|
|
|
# curl / wget
|
|
if has curl; then skip "curl"; else CORE_PKGS+=(curl); fi
|
|
if has wget; then skip "wget"; else CORE_PKGS+=(wget); fi
|
|
|
|
# zsh
|
|
if has zsh; then skip "zsh"; else CORE_PKGS+=(zsh); fi
|
|
|
|
# psmisc (fuser) and procps (pgrep)
|
|
if has fuser; then skip "fuser (psmisc)"; else
|
|
case $PM in
|
|
apt|pacman) CORE_PKGS+=(psmisc) ;;
|
|
brew) skip "fuser (not needed on macOS)" ;;
|
|
esac
|
|
fi
|
|
if has pgrep; then skip "pgrep (procps)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(procps) ;;
|
|
pacman) CORE_PKGS+=(procps-ng) ;;
|
|
brew) skip "pgrep (built-in on macOS)" ;;
|
|
esac
|
|
fi
|
|
|
|
# script (bsdutils on apt, util-linux on pacman, built-in on macOS)
|
|
if has script; then skip "script (bsdutils)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(bsdutils) ;;
|
|
pacman) CORE_PKGS+=(util-linux) ;;
|
|
brew) skip "script (built-in on macOS)" ;;
|
|
esac
|
|
fi
|
|
|
|
# build tools (make, gcc, g++) — needed for native npm modules like node-pty
|
|
if has make && has gcc; then skip "build tools (make, gcc, g++)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(build-essential) ;;
|
|
pacman) CORE_PKGS+=(base-devel) ;;
|
|
brew) warn "Install Xcode command line tools: xcode-select --install" ;;
|
|
esac
|
|
fi
|
|
|
|
# pkg-config — needed by cgo-based Go packages (e.g. ebitengine/oto for cliamp)
|
|
if has pkg-config; then skip "pkg-config"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(pkg-config) ;;
|
|
pacman) CORE_PKGS+=(pkgconf) ;;
|
|
brew) CORE_PKGS+=(pkg-config) ;;
|
|
esac
|
|
fi
|
|
|
|
# python3 + pip + venv
|
|
if has python3; then skip "python3"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(python3 python3-pip python3-venv) ;;
|
|
pacman) CORE_PKGS+=(python python-pip) ;;
|
|
brew) CORE_PKGS+=(python3) ;;
|
|
esac
|
|
fi
|
|
# ensure pip/venv even if python3 already exists (apt splits them)
|
|
if has python3 && [ "$PM" = "apt" ]; then
|
|
if ! dpkg -s python3-pip &>/dev/null 2>&1; then CORE_PKGS+=(python3-pip); fi
|
|
if ! dpkg -s python3-venv &>/dev/null 2>&1; then CORE_PKGS+=(python3-venv); fi
|
|
fi
|
|
|
|
# shell utilities
|
|
for tool in tree btop tmux jq htop lsof duf; do
|
|
if has "$tool"; then skip "$tool"; else CORE_PKGS+=("$tool"); fi
|
|
done
|
|
|
|
# sqlite3
|
|
if has sqlite3; then skip "sqlite3"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(sqlite3) ;;
|
|
pacman) CORE_PKGS+=(sqlite) ;;
|
|
brew) CORE_PKGS+=(sqlite) ;;
|
|
esac
|
|
fi
|
|
|
|
# isync (provides mbsync for Gmail IMAP sync)
|
|
if has mbsync; then skip "isync (mbsync)"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(isync) ;;
|
|
pacman) CORE_PKGS+=(isync) ;;
|
|
brew) CORE_PKGS+=(isync) ;;
|
|
esac
|
|
fi
|
|
|
|
# ripgrep
|
|
if has rg; then skip "ripgrep"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(ripgrep) ;;
|
|
pacman) CORE_PKGS+=(ripgrep) ;;
|
|
brew) CORE_PKGS+=(ripgrep) ;;
|
|
esac
|
|
fi
|
|
|
|
# fd-find
|
|
if has fd || has fdfind; then skip "fd-find"; else
|
|
case $PM in
|
|
apt) CORE_PKGS+=(fd-find) ;;
|
|
pacman) CORE_PKGS+=(fd) ;;
|
|
brew) CORE_PKGS+=(fd) ;;
|
|
esac
|
|
fi
|
|
|
|
# net-tools, less, file, man-db
|
|
case $PM in
|
|
apt)
|
|
for pkg in net-tools less file man-db; do
|
|
if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
pacman)
|
|
for pkg in net-tools less file man-db; do
|
|
if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi
|
|
done
|
|
;;
|
|
brew)
|
|
skip "net-tools, less, file, man (built-in on macOS)"
|
|
;;
|
|
esac
|
|
|
|
# locales
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s locales &>/dev/null 2>&1; then skip "locales"; else CORE_PKGS+=(locales); fi
|
|
;;
|
|
esac
|
|
|
|
# ca-certificates
|
|
case $PM in
|
|
apt)
|
|
if dpkg -s ca-certificates &>/dev/null 2>&1; then skip "ca-certificates"; else CORE_PKGS+=(ca-certificates); fi
|
|
;;
|
|
esac
|
|
|
|
# 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/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 | sh
|
|
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 ""
|