diff --git a/ecosystem.light.config.cjs b/ecosystem.light.config.cjs new file mode 100644 index 00000000..0dbaa496 --- /dev/null +++ b/ecosystem.light.config.cjs @@ -0,0 +1,75 @@ +// Linux light profile — the same process set as the Mac laptop build, on a Linux host. +// +// For a machine that should run the platform without the self-hosted estate around it: the file +// browser, the terminal, and Claude/opencode chat. Everything else in ecosystem.config.cjs either +// fronts a container, supervises a daemon, needs an owner-configured external service, or holds +// material a small install has no business holding. +// +// DERIVED, NOT COPIED — and that is the whole point. ecosystem.mac.config.cjs was a hand-copied +// process list, and within days of being written it was running a sidecar that had been split in two +// and pointing at a pty entry point that had moved. Both failures were silent. Here the entry points +// come from ecosystem.config.cjs, so a `script`/`args` change on the host reaches this profile for +// free, and a REMOVED or RENAMED app throws at load instead of quietly starting nothing. +// +// To change what the light profile runs, edit LIGHT_APPS. To change how an app is launched, edit +// ecosystem.config.cjs and both profiles follow. +// +// Start with: pm2 startOrRestart ecosystem.light.config.cjs + +const full = require('./ecosystem.config.cjs'); + +const LIGHT_APPS = [ + 'officer', // the app itself: SPA, /api, websockets + 'officer-anthropic-proxy', // holds the Anthropic credential, forwards upstream + 'officer-agent', // spawns `claude` — chat is dead without it + 'officer-opencode', // the alternative agent + 'officer-pty', // the terminal +]; + +// Everything in ecosystem.config.cjs that is deliberately NOT here, with the reason. Kept as data so +// the two lists can be checked against each other below: an app that is in neither is a mistake, and +// saying so at load beats discovering it when a feature silently does nothing. +const EXCLUDED = { + 'officer-vnc': 'mirrors an Xorg display with x11vnc; a light install has no desktop to mirror', + 'officer-email': 'needs the mbsync/IMAP stack the light profile does not install', + 'officer-music': 'the ffprobe indexer works, but a full library index is not a light-install concern', + 'officer-vault': 'reverse-proxies a self-hosted Vaultwarden container', + 'officer-slskd': 'supervises the slskd daemon', + 'officer-headscale': 'fronts a headscale server', + 'officer-transmission': 'fronts a transmission daemon', + 'officer-invoiceshelf': 'fronts an InvoiceShelf container', + 'officer-memos': 'needs an owner-configured Memos instance URL and token', + 'officer-photos': 'needs an owner-configured Immich instance URL and API key', + 'officer-caldav': 'supervises Radicale, which the light profile does not install', + 'officer-notify': 'its producers are the queue and the email/agent sidecars; nothing to notify about', + 'officer-wallet': 'holds seed and node credentials', +}; + +const byName = new Map(full.apps.map((app) => [app.name, app])); + +// A name in LIGHT_APPS that the host no longer defines is the exact failure that broke the Mac file. +// Fail loudly at load rather than start a short list and look healthy. +const missing = LIGHT_APPS.filter((name) => !byName.has(name)); +if (missing.length) { + throw new Error( + `ecosystem.light.config.cjs: ${missing.join(', ')} not found in ecosystem.config.cjs — ` + + `the app was renamed or removed. Update LIGHT_APPS.`, + ); +} + +// And an app added to the host that nobody has classified: it belongs in LIGHT_APPS or in EXCLUDED. +// Without this, a new sidecar silently defaults to "not in the light profile" and nobody decides. +const unclassified = full.apps.map((app) => app.name).filter((name) => !LIGHT_APPS.includes(name) && !(name in EXCLUDED)); +if (unclassified.length) { + throw new Error( + `ecosystem.light.config.cjs: ${unclassified.join(', ')} is in ecosystem.config.cjs but neither ` + + `included nor excluded here. Add it to LIGHT_APPS or to EXCLUDED with a reason.`, + ); +} + +// `cwd` is pinned because Bun auto-loads .env from the working directory (and the pty sidecar does +// `import 'dotenv/config'`). Without it, starting pm2 from anywhere but the repo root silently falls +// back to PORT=5000 with no POSTGRES_URL. +module.exports = { + apps: LIGHT_APPS.map((name) => ({ ...byName.get(name), cwd: __dirname })), +}; diff --git a/scripts/setup.sh b/scripts/setup.sh index e15a7f80..ed4055a7 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,7 +1,25 @@ #!/bin/bash -# Officer — full host dependency setup +# Officer — host dependency setup # Run once on a fresh Ubuntu/Debian host before launching the server. -# Usage: bash scripts/setup.sh +# +# 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 @@ -14,9 +32,21 @@ 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")" @@ -228,6 +258,10 @@ fi echo "" echo "── Archive utilities (optional) ──" +if is_light; then + omit "7z, unrar" +else + ARCHIVE_PKGS=() # p7zip @@ -252,6 +286,7 @@ 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 "" @@ -268,6 +303,13 @@ fi 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" @@ -344,6 +386,7 @@ if [ "$LOGIND_CHANGED" = "1" ]; then else skip "logind auto-suspend settings" fi +fi # ─── 5. Node.js 22 (system-wide) ───────────────────────────────────────────── echo "" @@ -433,6 +476,15 @@ 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 ──" @@ -735,6 +787,8 @@ else 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) ──" @@ -813,9 +867,15 @@ elif [ -f "$PROJECT_DIR/.env" ]; then fi if [ "$GENERATE_ENV" = true ]; then - # Run setup-dockers.sh and capture its stdout output + # 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..." - DOCKER_OUTPUT=$(bash "$SCRIPT_DIR/setup-dockers.sh") + 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-) @@ -893,14 +953,21 @@ echo "── Remote Desktop (Ubuntu Desktop + VNC) ──" # 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. -case $PM in - apt) - bash "$SCRIPT_DIR/setup-desktop.sh" - ;; - *) - warn "Remote desktop setup is Ubuntu/Debian only — skipping" - ;; -esac +# 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 "" @@ -932,14 +999,14 @@ 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-claude. -if ! has pm2 || [ ! -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then - warn "Skipping service start (pm2 or ecosystem.config.cjs missing)" +# 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..." - if (cd "$PROJECT_DIR" && pm2 startOrRestart ecosystem.config.cjs); then + 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'" @@ -988,14 +1055,18 @@ check gcc echo "" echo "Dev tools:" -check go -check rustc -check cargo -check nvim +# 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 starship -check lazygit -check eza check rg check fd check jq @@ -1005,12 +1076,14 @@ check tree check btop check sqlite3 -echo "" -echo "Audio (cliamp):" -check pulseaudio -check parec -check pactl -check cliamp +if ! is_light; then + echo "" + echo "Audio (cliamp):" + check pulseaudio + check parec + check pactl + check cliamp +fi echo "" echo "AI agents:" @@ -1027,18 +1100,24 @@ check 7z check unrar check pgrep check fuser -check yt-dlp +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 -# ecosystem.config.cjs so it cannot drift as sidecars are added. -if has pm2 && [ -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then +# 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:" - for app in $(grep -oE "name: *'[^']+'" "$PROJECT_DIR/ecosystem.config.cjs" | sed "s/.*'\(.*\)'/\1/"); do + 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