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 })),
};
+114 -35
View File
@@ -1,7 +1,25 @@
#!/bin/bash #!/bin/bash
# Officer — full host dependency setup # Officer — host dependency setup
# Run once on a fresh Ubuntu/Debian host before launching the server. # 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 set -e
@@ -14,9 +32,21 @@ ok() { echo -e " ${GREEN}✓${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; } warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; } fail() { echo -e " ${RED}${NC} $1"; }
skip() { echo -e " - $1 (already installed)"; } skip() { echo -e " - $1 (already installed)"; }
omit() { echo -e " - $1 (skipped: light profile)"; }
has() { command -v "$1" &>/dev/null; } 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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
@@ -228,6 +258,10 @@ fi
echo "" echo ""
echo "── Archive utilities (optional) ──" echo "── Archive utilities (optional) ──"
if is_light; then
omit "7z, unrar"
else
ARCHIVE_PKGS=() ARCHIVE_PKGS=()
# p7zip # p7zip
@@ -252,6 +286,7 @@ if [ ${#ARCHIVE_PKGS[@]} -gt 0 ]; then
install_pkg "${ARCHIVE_PKGS[@]}" || warn "Some archive packages may need non-free repos" install_pkg "${ARCHIVE_PKGS[@]}" || warn "Some archive packages may need non-free repos"
ok "Installed: ${ARCHIVE_PKGS[*]}" ok "Installed: ${ARCHIVE_PKGS[*]}"
fi fi
fi
# ─── 3. ffmpeg ───────────────────────────────────────────────────────────────── # ─── 3. ffmpeg ─────────────────────────────────────────────────────────────────
echo "" echo ""
@@ -268,6 +303,13 @@ fi
echo "" echo ""
echo "── Sudoers (Linux user isolation) ──" 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)" SERVICE_USER="$(whoami)"
SUDOERS_FILE="/etc/sudoers.d/officer-service" SUDOERS_FILE="/etc/sudoers.d/officer-service"
@@ -344,6 +386,7 @@ if [ "$LOGIND_CHANGED" = "1" ]; then
else else
skip "logind auto-suspend settings" skip "logind auto-suspend settings"
fi fi
fi
# ─── 5. Node.js 22 (system-wide) ───────────────────────────────────────────── # ─── 5. Node.js 22 (system-wide) ─────────────────────────────────────────────
echo "" echo ""
@@ -433,6 +476,15 @@ else
skip "bun symlink at /usr/local/bin/bun" skip "bun symlink at /usr/local/bin/bun"
fi 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 ───────────────────────────────────────────────────────────────────── # ─── 7. Go ─────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "── Go ──" echo "── Go ──"
@@ -735,6 +787,8 @@ else
esac esac
fi fi
fi # end of the light-profile skip for sections 7-13
# ─── 14. npm global packages (user-local) ─────────────────────────────────── # ─── 14. npm global packages (user-local) ───────────────────────────────────
echo "" echo ""
echo "── npm global packages (user-local) ──" echo "── npm global packages (user-local) ──"
@@ -813,9 +867,15 @@ elif [ -f "$PROJECT_DIR/.env" ]; then
fi fi
if [ "$GENERATE_ENV" = true ]; then 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..." 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 # Parse output from setup-dockers.sh
COMPOSE_DIR=$(echo "$DOCKER_OUTPUT" | grep '^COMPOSE_DIR=' | cut -d= -f2-) 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 # 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 # guarded — so letting it run each time converges a partially configured host instead of trusting a
# proxy for state it never actually checked. # 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
apt) # expensive section — it pulls the whole ubuntu-desktop meta-package — and the one most clearly outside
bash "$SCRIPT_DIR/setup-desktop.sh" # "file browser, terminal, chat".
;; if is_light; then
*) omit "remote desktop (ubuntu-desktop, GDM, x11vnc, Brave)"
warn "Remote desktop setup is Ubuntu/Debian only — skipping" else
;; case $PM in
esac apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
fi
# ─── 18. project initialization ────────────────────────────────────────────── # ─── 18. project initialization ──────────────────────────────────────────────
echo "" 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 # 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 # 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. # app: /desktop returns 503 until officer-vnc is connected, and chat needs officer-agent.
if ! has pm2 || [ ! -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then if ! has pm2 || [ ! -f "$PROJECT_DIR/$ECOSYSTEM_FILE" ]; then
warn "Skipping service start (pm2 or ecosystem.config.cjs missing)" warn "Skipping service start (pm2 or $ECOSYSTEM_FILE missing)"
else else
# startOrRestart also picks up apps added to the ecosystem since the last run. These are all # 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. # fork-mode apps, so reload would buy nothing over restart.
echo " Starting Officer and its sidecars..." echo " Starting Officer and its sidecars ($ECOSYSTEM_FILE)..."
if (cd "$PROJECT_DIR" && pm2 startOrRestart ecosystem.config.cjs); then if (cd "$PROJECT_DIR" && pm2 startOrRestart "$ECOSYSTEM_FILE"); then
ok "services started" ok "services started"
else else
fail "pm2 could not start the services — check 'pm2 logs'" fail "pm2 could not start the services — check 'pm2 logs'"
@@ -988,14 +1055,18 @@ check gcc
echo "" echo ""
echo "Dev tools:" echo "Dev tools:"
check go # Only the ones the light profile actually installs are checked under it — reporting Go and cliamp as
check rustc # NOT FOUND on an install that deliberately skipped them makes a clean run look broken.
check cargo if ! is_light; then
check nvim check go
check rustc
check cargo
check nvim
check starship
check lazygit
check eza
fi
check zsh check zsh
check starship
check lazygit
check eza
check rg check rg
check fd check fd
check jq check jq
@@ -1005,12 +1076,14 @@ check tree
check btop check btop
check sqlite3 check sqlite3
echo "" if ! is_light; then
echo "Audio (cliamp):" echo ""
check pulseaudio echo "Audio (cliamp):"
check parec check pulseaudio
check pactl check parec
check cliamp check pactl
check cliamp
fi
echo "" echo ""
echo "AI agents:" echo "AI agents:"
@@ -1027,18 +1100,24 @@ check 7z
check unrar check unrar
check pgrep check pgrep
check fuser check fuser
check yt-dlp if ! is_light; then check yt-dlp; fi
echo "" echo ""
echo "═══════════════════════════════════════════" echo "═══════════════════════════════════════════"
echo " Setup complete!" echo " Setup complete!"
echo "═══════════════════════════════════════════" echo "═══════════════════════════════════════════"
# Services actually running is a better signal than the binaries being present. The list comes from # Services actually running is a better signal than the binaries being present. The list comes from the
# ecosystem.config.cjs so it cannot drift as sidecars are added. # ecosystem this install started, so it cannot drift as sidecars are added.
if has pm2 && [ -f "$PROJECT_DIR/ecosystem.config.cjs" ]; then #
# 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 ""
echo "Services:" 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 if pm2 pid "$app" >/dev/null 2>&1 && [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]')" ]; then
ok "$app" ok "$app"
else else