scripts: add a light install profile

OFFICER_PROFILE=light installs the same thing the macOS build does, on Linux:
the file browser, the terminal, and Claude/opencode chat. It skips the archive
extras, the sudoers entry and auto-suspend disabling, Go, Rust, PulseAudio,
cliamp, Neovim, the shell tooling and yt-dlp, brings up Postgres alone of the
five Docker services, and starts ecosystem.light.config.cjs. Unset or `full`
behaves exactly as before.

The profile changes which processes start, not which code ships — every API
route stays mounted, so features whose sidecars are absent report themselves
unavailable rather than disappearing.

ecosystem.light.config.cjs DERIVES its apps from ecosystem.config.cjs rather
than copying them, because the hand-copied Mac list was broken within days of
being written by a sidecar split in two and a pty entry point that moved, and
both failures were silent. Here a script/args change on the host propagates for
free, and two consistency checks turn the silent cases loud:

- a name the profile needs that the host no longer defines throws at load
- an app added to the host that is in neither the include list nor the annotated
  exclusion list throws, so a new sidecar cannot default to "not in the profile"
  without someone deciding

Both were tested against a mutated copy of the host ecosystem: renaming
officer-agent and adding an unclassified sidecar each throw, and the unmodified
file loads five apps.

The verification block now reads app names with node instead of grepping for
`name:` — the derived file has no literal keys to match, so a grep would have
silently verified nothing — and skips the checks for tools the profile did not
install, so a clean light run does not report Go and cliamp as missing.

Full-profile behaviour is unchanged by construction: every guard wraps the
original code in an else branch. The preamble was tested across unset, full,
light and an invalid value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 12:41:47 +00:00
co-authored by Claude Opus 5
parent 37ff7c3103
commit 1f4dbbb810
2 changed files with 189 additions and 35 deletions
+75
View File
@@ -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 })),
};
+107 -28
View File
@@ -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..."
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
# 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
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