"This folder is empty" was a lie. The five seeded directories were sitting there and the platform's readdir raised EACCES: a member's home is 700 and owned by them, which is correct for a shell and locks out the file browser, which runs inside the platform process. /ls caught the error and returned an empty listing, so a refusal looked exactly like data. Two doors, two boundaries, and that is the point rather than a compromise. The terminal and the agent RUN AS the member and the kernel is the boundary there. The file browser acts on the member's behalf from inside the platform, which already applies its own containment and is the owner's process on the owner's machine — it can read anything via sudo regardless. Giving it access describes who is doing the work. Done with named POSIX ACLs, because it has to hold in BOTH directions: a file the platform writes must be editable by the member and vice versa. Mode bits cannot say that — whichever party is neither owner nor group lands in "other", and widening "other" opens the home to every account on the box. A shared group fails the same way, since both parties would have to be in it and that puts every member in a group that can read every other member's home. Two named entries plus `d:` defaults grant exactly two users and are inherited by whatever either side creates, whatever their umask. Verified: platform lists the home, member edits a platform-written file, platform edits a member-written file, and a SECOND member is refused on both ls and cat. /ls now distinguishes EACCES from a missing directory. An empty result is data and must never be how a refusal looks. acl joins the core packages in setup.sh — the alternative is an account that provisions and then cannot list its own home. Also: the file browser's own useTasks/useAgents fired /tasks, /agents and both category endpoints on every render, which is where the last four 403s came from — they are the context menu's Run Task and agent submenus, execution-only. Gated. And plans is deleted: router, screen, routes, dock tile, hook, page title and its capability. It read markdown from <repo>/plans, which does not exist. Fresh-install Permissions is now Files alone, with Terminal to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1163 lines
42 KiB
Bash
Executable File
1163 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 tooling (starship/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
|
|
|
|
# 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 tooling (starship/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 ──────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──"
|
|
|
|
# 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
|
|
|
|
# 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 ""
|