put the superseded setup scripts in setup-old, and repair what the move broke

scripts/setup/ is now what the new installer is being built in — machine-setup/
for the box, officer-setup.sh for the platform on top — and everything being
replaced moved to scripts/setup-old/. It still works and is still what to run.

Three things the move broke, and what each needed:

  starship.toml is not an old-setup artifact. os-user-shell.ts reads it at
  RUNTIME to seed a member's ~/.config/starship.toml when their Linux account is
  provisioned, and line 125 reads it inside a try whose catch returns
  "could not read the shell templates" — so account provisioning would have
  failed outright, not degraded. Moved back to scripts/setup/, which is where it
  belongs anyway (one file, both audiences) and which leaves the code correct
  with no edit.

  package.json's `setup` script pointed at a path that no longer exists. It now
  points at officer-setup.sh, where the installer is going, rather than at
  setup-old/ which is temporary.

  officer-setup.sh was created empty. An empty script exits 0, so `bun setup`
  would have reported success while doing nothing — worse than the broken path
  it replaced. It now explains that it is not written yet and exits 1, naming
  the setup-old script to run meanwhile.

Also brought .tmux.conf and ufw-docker-rules.conf in beside machine-setup.sh,
which reads both from SCRIPT_DIR and had been silently skipping them since the
script was vendored. ssh-keys.zip deliberately stays out: it is key material,
and *.zip is ignored.

Comments in os-user-claude.ts, app-store/preflight.ts and two docs still name the
old scripts/setup/setup.sh path. Left alone on purpose — repointing them at
setup-old/ only to repoint them again when officer-setup.sh lands is churn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 17:07:28 +00:00
co-authored by Claude Opus 5
parent 44141faf0a
commit 34bb8fc22a
10 changed files with 164 additions and 1 deletions
+100
View File
@@ -0,0 +1,100 @@
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
# Auto-install TPM if missing
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
# Plugin list
set -g @plugin 'tmux-plugins/tpm'
# remap prefix from 'C-b' to 'C-a'
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
set -g base-index 1
# split panes using | and -
unbind '"'
unbind %
bind | split-window -h
bind - split-window -v
# reload config file (change file location to your the tmux.conf you want to use)
unbind r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
# switch panes using Alt-arrow without prefix
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D
# switch panes using Alt-HJKL without prefix
bind -n M-h select-pane -L
bind -n M-l select-pane -R
bind -n M-k select-pane -U
bind -n M-j select-pane -D
# Enable mouse control (clickable windows, panes, resizable panes)
# don't rename windows automatically
set-option -g allow-rename off
######################
### DESIGN CHANGES ###
######################
# loud or quiet?
set -g visual-activity off
set -g visual-bell off
set -g visual-silence off
setw -g monitor-activity off
set -g bell-action none
# modes
setw -g clock-mode-colour colour12
setw -g mode-style 'fg=colour1 bg=colour18 bold'
# panes
set -g pane-border-style 'fg=colour19 bg=colour0'
set -g pane-active-border-style 'bg=colour0 fg=colour9'
# statusbar
set -g status-position bottom
set -g status-justify left
set -g status-style 'bg=colour2 fg=colour23'
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
# set -g status-left-length 20
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
set -g status-right-length 50
set -g status-left-length 20
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
setw -g window-status-style 'fg=colour9 bg=colour18'
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
# ...existing code...
# messages
set -g message-style 'fg=#ffffff bg=red bold'
# Change the font color for the exit pane confirmation message
set -g message-command-style 'fg=#ffffff bg=red bold'
# ...existing code...
# messages
# set -g message-style 'fg=colour232 bg=colour16 bold'
##########################
### END DESIGN CHANGES ###
##########################
##########################
### EASY MOUSE SCROLL ###
##########################
set -g mouse on
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
@@ -0,0 +1,33 @@
# UFW Docker compatibility rules
# Append these to /etc/ufw/after.rules (after the existing COMMIT)
# Blocks all external access to Docker-published ports except:
# - Trusted IPs (add your own)
# - Explicitly allowed public ports (80, 443)
# - Docker internal and loopback traffic
*filter
:DOCKER-USER - [0:0]
# Allow established/related
-A DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
# Allow loopback
-A DOCKER-USER -i lo -j RETURN
# Allow Docker internal networks
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
# Allow trusted external sources (add more lines as needed)
# -A DOCKER-USER -s <TRUSTED_IP> -j RETURN
# Allow public ports
-A DOCKER-USER -i eth0 -p tcp --dport 80 -j RETURN
-A DOCKER-USER -i eth0 -p tcp --dport 443 -j RETURN
# Drop everything else from external
-A DOCKER-USER -i eth0 -j DROP
# Return for non-external traffic
-A DOCKER-USER -j RETURN
COMMIT
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# Pick the largest sane mode on whichever output is connected, at session start.
#
# With no monitor attached the connector's EDID comes from drm.edid_firmware (see the kernel command
# line). That EDID advertises plenty of modes but its *preferred* one is the captured screen's native
# resolution, which may be tiny — GNOME picks preferred, so an 800x480 panel yields an 800x480
# desktop. monitors.xml is the documented override but its monitor matching proved unreliable here,
# so set the mode directly. No-ops when the mode is already right, so it is safe to run repeatedly.
set -eu
MAX_WIDTH="${OFFICER_MAX_WIDTH:-1920}"
# The session's X server may not be up yet when autostart fires.
for _ in $(seq 1 20); do
xrandr --query >/dev/null 2>&1 && break
sleep 0.5
done
xrandr --query >/dev/null 2>&1 || exit 0
output=$(xrandr --query | awk '/ connected/ { print $1; exit }')
[ -n "$output" ] || exit 0
# Modes are listed under the output but are not reliably sorted, so pick the widest that fits.
mode=$(
xrandr --query \
| sed -n "/^${output} connected/,/^[^ ]/p" \
| awk '/^[[:space:]]+[0-9]+x[0-9]+/ { print $1 }' \
| awk -F x -v max="$MAX_WIDTH" '
{ w = $1 + 0; h = $2 + 0 }
w <= max && (w > bw || (w == bw && h > bh)) { bw = w; bh = h; best = $0 }
END { if (best) print best }'
)
[ -n "$mode" ] || exit 0
current=$(xrandr --query | awk -v o="$output" '$1 == o { print $3 }' | cut -d+ -f1)
[ "$current" = "$mode" ] && exit 0
xrandr --output "$output" --mode "$mode" || true
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -e
# =============================================================================
# officer-setup — the platform, on a machine that is already provisioned
#
# The second half of the install. machine-setup/ brings a blank box up to a
# usable machine; this puts Officer on top of it: dependencies, .env, the
# database schema, and the pm2 process list.
#
# Run: bash scripts/setup/officer-setup.sh
# =============================================================================
#
# NOT WRITTEN YET. It is being rebuilt from scripts/setup-old/setup.sh, which
# still works and is still the one to run.
#
# This file exists ahead of its contents so that `bun setup` points at where the
# installer is going rather than at a path that is about to move again. It fails
# loudly rather than sitting empty, because an empty script exits 0 and would
# make `bun setup` report success while doing nothing at all.
echo ""
echo " officer-setup.sh is not written yet."
echo ""
echo " It is being rebuilt from scripts/setup-old/setup.sh, which still works:"
echo ""
echo " bash scripts/setup-old/setup.sh # full install"
echo " OFFICER_PROFILE=light bash scripts/setup-old/setup.sh # light install"
echo ""
exit 1
-173
View File
@@ -1,173 +0,0 @@
#!/bin/bash
set -euo pipefail
# Officer Remote Desktop Setup — Ubuntu GNOME desktop on Xorg, mirrored over VNC.
#
# The platform mirrors the single physical display :0 with x11vnc (see vnc-manager.ts). x11vnc can only
# capture an Xorg server, NOT a Wayland compositor — so we install the full GNOME desktop but force GDM
# onto the Xorg session (WaylandEnable=false). Auto-login is enabled so a user session owns :0 for the
# mirror to attach to. Switching the display manager takes effect on the next reboot.
# Usage: ./scripts/setup/setup-desktop.sh
echo "=== Officer Remote Desktop Setup (Ubuntu GNOME on Xorg) ==="
echo ""
DESKTOP_USER="$(whoami)"
# --- Step 1: Install GNOME desktop + GDM + x11vnc ---
echo "[1/6] Installing ubuntu-desktop, GDM, x11vnc..."
sudo apt update -qq
sudo DEBIAN_FRONTEND=noninteractive apt install -y -qq \
ubuntu-desktop \
gdm3 \
x11vnc \
dbus-x11
echo " Done."
# No tigervnc package is needed. The VNC sidecar writes the .vnc/passwd rfbauth file with
# `x11vnc -storepasswd` (see vnc-manager.ts). tigervnc-common was installed here for `vncpasswd`,
# but it does not ship that binary — it only provides tigervncconfig — so the rfbauth file was never
# created and the desktop could not authenticate.
# --- Step 2: Force GDM onto Xorg + enable auto-login (x11vnc cannot mirror Wayland) ---
echo "[2/6] Forcing Xorg session and auto-login in GDM..."
GDM_CONF=/etc/gdm3/custom.conf
sudo mkdir -p /etc/gdm3
[ -f "$GDM_CONF" ] || echo "[daemon]" | sudo tee "$GDM_CONF" > /dev/null
# Ensure a [daemon] section exists to anchor the keys under.
sudo grep -qE '^\[daemon\]' "$GDM_CONF" || echo "[daemon]" | sudo tee -a "$GDM_CONF" > /dev/null
# Set key=value under [daemon]: rewrite an existing (possibly commented) line, else insert after [daemon].
set_gdm_key() {
local key="$1" val="$2"
if sudo grep -qE "^[[:space:]]*#?[[:space:]]*${key}=" "$GDM_CONF"; then
sudo sed -i "s|^[[:space:]]*#\?[[:space:]]*${key}=.*|${key}=${val}|" "$GDM_CONF"
else
sudo sed -i "/^\[daemon\]/a ${key}=${val}" "$GDM_CONF"
fi
}
set_gdm_key WaylandEnable false
set_gdm_key AutomaticLoginEnable true
set_gdm_key AutomaticLogin "$DESKTOP_USER"
echo " Xorg forced (WaylandEnable=false), auto-login as $DESKTOP_USER."
# --- Step 3: Make GDM the default display manager ---
echo "[3/6] Setting GDM as the default display manager..."
echo "/usr/sbin/gdm3" | sudo tee /etc/X11/default-display-manager > /dev/null
sudo systemctl enable gdm3 >/dev/null 2>&1 || sudo systemctl enable gdm >/dev/null 2>&1 || true
sudo systemctl set-default graphical.target >/dev/null 2>&1 || true
# Disable any prior display manager so it doesn't fight GDM. lightdm is named because earlier versions
# of this script installed XFCE; a host set up back then still has it and it would win the boot race.
sudo systemctl disable lightdm >/dev/null 2>&1 || true
echo " Done (takes effect on next reboot)."
# --- Step 4: Install Brave browser (native .deb, not snap) ---
echo "[4/6] Installing Brave browser..."
if ! command -v brave-browser-stable > /dev/null 2>&1; then
sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \
https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/brave-browser-archive-keyring.gpg] https://brave-browser-apt-release.s3.brave.com/ stable main" \
| sudo tee /etc/apt/sources.list.d/brave-browser-release.list > /dev/null
sudo apt update -qq
sudo DEBIAN_FRONTEND=noninteractive apt install -y -qq brave-browser
fi
# Fix launcher symlink
if [ -f /opt/brave.com/brave/brave-browser ]; then
sudo rm -f /usr/bin/brave-browser-stable
sudo ln -s /opt/brave.com/brave/brave-browser /usr/bin/brave-browser-stable
fi
# Tell Brave to use basic password store (no keyring prompts)
sudo mkdir -p /etc/brave
echo '--password-store=basic' | sudo tee /etc/brave/brave-flags.conf > /dev/null
echo " Done."
# --- Step 5: Remove GNOME Keyring + set default browser (prevents password prompts on login) ---
echo "[5/6] Removing GNOME Keyring and setting default browser..."
sudo apt remove -y --purge gnome-keyring > /dev/null 2>&1 || true
rm -rf ~/.local/share/keyrings
if command -v brave-browser-stable > /dev/null 2>&1; then
sudo update-alternatives --set x-www-browser /opt/brave.com/brave/brave 2>/dev/null || true
echo " Brave set as default."
else
echo " No supported browser found, skipping default."
fi
# --- Step 6: Headless display (keep a desktop when no monitor is attached) ---
echo "[6/6] Configuring the headless display..."
# x11vnc mirrors :0, but with nothing plugged in the connector has no EDID and no CRTC, so GNOME
# renders nothing and the mirror is black. Replaying a real monitor's EDID makes the connector look
# permanently attached. The EDID has to be captured from a screen that is plugged in *now* — there is
# nothing to copy otherwise — so this step is skipped on a headless run and can be re-run later.
EDID_DIR=/lib/firmware/edid
EDID_FILE="$EDID_DIR/officer-screen.bin"
CONNECTED_SYSFS=""
for c in /sys/class/drm/card*-*/status; do
[ "$(cat "$c" 2>/dev/null)" = "connected" ] || continue
CONNECTED_SYSFS="$(basename "$(dirname "$c")")"
break
done
if [ -z "$CONNECTED_SYSFS" ] && [ ! -f "$EDID_FILE" ]; then
echo " ! No display connected and no EDID saved — skipping."
echo " Plug a monitor in and re-run this script to capture one, otherwise the remote"
echo " desktop will be black whenever nothing is attached."
else
# cardN-HDMI-A-1 -> HDMI-A-1, which is the name the kernel parameters use (card numbering can
# change between boots; the connector name does not).
if [ -n "$CONNECTED_SYSFS" ]; then
CONNECTOR="${CONNECTED_SYSFS#*-}"
sudo mkdir -p "$EDID_DIR"
sudo cp "/sys/class/drm/$CONNECTED_SYSFS/edid" "$EDID_FILE"
echo " Captured EDID from $CONNECTOR ($(stat -c%s "$EDID_FILE") bytes)."
echo "$CONNECTOR" | sudo tee "$EDID_DIR/officer-connector" > /dev/null
else
CONNECTOR="$(cat "$EDID_DIR/officer-connector" 2>/dev/null || echo HDMI-A-1)"
echo " Reusing the saved EDID for $CONNECTOR."
fi
# drm.edid_firmware makes the connector report that EDID; the trailing "e" on video= forces it
# enabled with nothing attached. amdgpu is not in the initramfs, so /lib/firmware is readable by
# the time the driver loads and no initramfs rebuild is needed.
KERNEL_ARGS="drm.edid_firmware=$CONNECTOR:edid/officer-screen.bin video=$CONNECTOR:1920x1080e"
if grep -q "drm.edid_firmware=" /etc/default/grub; then
echo " GRUB already carries a forced EDID — leaving it alone."
else
sudo cp /etc/default/grub "/etc/default/grub.bak-$(date +%Y%m%d%H%M%S)"
CURRENT=$(grep "^GRUB_CMDLINE_LINUX_DEFAULT=" /etc/default/grub | sed 's/^[^"]*"//; s/"$//')
sudo sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT=\"${CURRENT:+$CURRENT }$KERNEL_ARGS\"|" /etc/default/grub
sudo update-grub > /dev/null 2>&1
echo " Added the forced EDID to the kernel command line (takes effect on reboot)."
fi
# The EDID's preferred mode is whatever the captured panel was, which may be small. GNOME picks
# preferred, so raise it at session start.
install -d "$HOME/.local/bin" "$HOME/.config/autostart"
install -m 755 "$(dirname "$0")/officer-set-display.sh" "$HOME/.local/bin/officer-set-display.sh"
cat > "$HOME/.config/autostart/officer-set-display.desktop" <<AUTOSTART
[Desktop Entry]
Type=Application
Name=Officer display mode
Comment=Raise the headless display to its largest usable mode at login
Exec=$HOME/.local/bin/officer-set-display.sh
X-GNOME-Autostart-enabled=true
NoDisplay=true
AUTOSTART
echo " Installed the login-time mode setter."
fi
echo " Done."
# --- Cleanup old systemd service if it exists ---
if systemctl list-unit-files officer-vnc.service &>/dev/null; then
echo ""
echo "Removing old officer-vnc systemd service..."
sudo systemctl stop officer-vnc 2>/dev/null || true
sudo systemctl disable officer-vnc 2>/dev/null || true
sudo rm -f /etc/systemd/system/officer-vnc.service
sudo systemctl daemon-reload
echo " Done."
fi
echo ""
echo "Setup complete. The screen mirror (x11vnc on :0) is managed by the VNC sidecar."
echo "REBOOT to switch into the GNOME-on-Xorg session with auto-login."
-299
View File
@@ -1,299 +0,0 @@
#!/bin/bash
# Officer — Docker Compose service setup
# Sets up Docker Compose services (Nginx Proxy Manager, PostgreSQL, Mailhog, Redis, SearXNG).
# Outputs parseable key=value lines to stdout; all prompts go to stderr.
#
# Usage:
# bash scripts/setup/setup-dockers.sh
# eval "$(bash scripts/setup/setup-dockers.sh)"
#
# Environment overrides:
# SETUP_DOCKER_SERVICES="1 2 3" — pre-select services (or "all"/"none")
# SETUP_DOCKER_NETWORK="services" — docker network name
# SETUP_NPM_BIND="100.64.0.8" — host address Nginx Proxy Manager publishes on. Defaults to this
# node's Tailscale IPv4; set it explicitly to bind somewhere else.
set -e
# 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
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
info() { echo -e "${CYAN}::${NC} $*" >&2; }
ok() { echo -e " ${GREEN}${NC} $1" >&2; }
warn() { echo -e " ${YELLOW}!${NC} $1" >&2; }
fail() { echo -e " ${RED}${NC} $1" >&2; }
prompt_value() {
local varname="$1" prompt="$2" default="$3"
if [[ -n "$default" ]]; then
echo -en " ${BOLD}${prompt}${NC} [${default}]: " >&2
else
echo -en " ${BOLD}${prompt}${NC}: " >&2
fi
read -r val
if [[ -z "$val" ]]; then val="$default"; fi
eval "$varname=\"\$val\""
}
# ─── docker network ─────────────────────────────────────────────────────────
DOCKER_NETWORK="${SETUP_DOCKER_NETWORK:-services}"
# ─── Nginx Proxy Manager bind address ───────────────────────────────────────
#
# NPM is the only service here that ever published on 0.0.0.0, and a published Docker port is not
# behind the firewall: Docker writes its DNAT rules directly into the nat table, which UFW's INPUT
# chain never sees. `ufw default deny incoming` does not cover 80/443/81 — that is what the host's
# ufw-docker-rules.conf exists to patch, and patching a rule is weaker than never opening the socket.
#
# So bind to the tailnet address instead. The kernel then refuses the socket on every other interface
# and the firewall stops being load-bearing for this. The address is read at run time rather than
# passed in, because by the time this script runs the host provisioning has already done `tailscale up`.
resolve_npm_bind() {
if [[ -n "${SETUP_NPM_BIND:-}" ]]; then
echo "$SETUP_NPM_BIND"
return
fi
local ip
ip=$(tailscale ip -4 2>/dev/null | head -1)
# 100.64.0.0/10 — the CGNAT range both Tailscale and Headscale allocate from. Anything outside it
# means `tailscale ip` answered with something unexpected, and a bind address is not a value to
# guess at: the whole point is that it is NOT reachable from the internet.
if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\. ]]; then
echo "$ip"
return
fi
echo ""
}
NPM_BIND="$(resolve_npm_bind)"
# Binding to an address that belongs to another service's interface makes that service a boot-order
# dependency: if tailscaled has not brought tailscale0 up yet, the container cannot get its socket and
# Docker falls back on the restart policy to retry. That converges, but only if the tailnet comes up
# at all on its own.
if [[ -n "$NPM_BIND" ]] && ! systemctl is-enabled --quiet tailscaled 2>/dev/null; then
warn "tailscaled is not enabled at boot — NPM binds $NPM_BIND, which will not exist after a reboot"
warn "until the tailnet is up. Fix with: sudo systemctl enable tailscaled"
fi
# Ensure network exists
if ! docker network inspect "$DOCKER_NETWORK" &>/dev/null; then
docker network create "$DOCKER_NETWORK" >/dev/null 2>&1
ok "Created docker network '$DOCKER_NETWORK'"
else
ok "Docker network '$DOCKER_NETWORK' exists"
fi
# ─── service selection ───────────────────────────────────────────────────────
SERVICES="${SETUP_DOCKER_SERVICES:-}"
if [[ "$SERVICES" == "none" ]]; then
info "Skipping Docker Compose services (SETUP_DOCKER_SERVICES=none)"
exit 0
fi
if [[ -z "$SERVICES" ]]; then
info "Select Docker Compose services to include:"
echo " [1] Nginx Proxy Manager (jc21/nginx-proxy-manager:latest)" >&2
echo " [2] PostgreSQL 18 (postgres:18-alpine)" >&2
echo " [3] Mailhog (mailhog/mailhog:latest)" >&2
echo " [4] Redis (redis:alpine)" >&2
echo " [5] SearXNG (searxng/searxng:latest)" >&2
prompt_value SERVICES "Enter numbers separated by spaces (e.g. '1 2 4'), or 'all'" "all"
fi
if [[ "$SERVICES" == "all" ]]; then
SERVICES="1 2 3 4 5"
fi
# ─── compose directory ──────────────────────────────────────────────────────
prompt_value COMPOSE_DIR "Docker Compose directory" "$REAL_HOME/.local/dockers"
mkdir -p "$COMPOSE_DIR"
# ─── build compose file ─────────────────────────────────────────────────────
cat > "$COMPOSE_DIR/docker-compose.yaml" <<'HEADER'
services:
HEADER
COMPOSE_SERVICES=()
PG_PASSWORD=""
MAILHOG_SELECTED=false
for svc in $SERVICES; do
case "$svc" in
1)
if [[ -z "$NPM_BIND" ]]; then
fail "Nginx Proxy Manager selected, but no Tailscale IPv4 was found on this host."
echo " Bring the tailnet up first (the host provisioning does this), or choose the" >&2
echo " address deliberately: SETUP_NPM_BIND=<ip> bash scripts/setup/setup-dockers.sh" >&2
echo " Publishing it on 0.0.0.0 is not offered — Docker bypasses UFW, so that would put" >&2
echo " 80/443/81 on every interface the host has." >&2
exit 1
fi
COMPOSE_SERVICES+=("nginx-proxy-manager")
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "$NPM_BIND:80:80"
- "$NPM_BIND:443:443"
- "$NPM_BIND:81:81"
volumes:
- ./npm_data:/data
- ./npm_letsencrypt:/etc/letsencrypt
networks:
- $DOCKER_NETWORK
SVC
;;
2)
COMPOSE_SERVICES+=("postgres")
PG_DEFAULT_PASS=$(openssl rand -base64 36 | tr -d '/+=' | head -c 36)
prompt_value PG_PASSWORD "PostgreSQL password" "$PG_DEFAULT_PASS"
prompt_value PG_DATABASE "PostgreSQL database name" "officer_dev"
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
postgres:
image: postgres:18.3-alpine
container_name: postgres
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_PASSWORD: $PG_PASSWORD
PGDATA: /var/lib/postgresql/data
volumes:
- ./postgres_data:/var/lib/postgresql/data
- ./db_dumps:/db_dumps
networks:
- $DOCKER_NETWORK
SVC
;;
3)
COMPOSE_SERVICES+=("mailhog")
MAILHOG_SELECTED=true
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog
restart: unless-stopped
ports:
- "127.0.0.1:1025:1025"
- "127.0.0.1:8025:8025"
networks:
- $DOCKER_NETWORK
SVC
;;
4)
COMPOSE_SERVICES+=("redis")
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
redis:
image: redis:alpine
container_name: redis
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
volumes:
- ./redis_data:/data
networks:
- $DOCKER_NETWORK
SVC
;;
5)
COMPOSE_SERVICES+=("searxng")
mkdir -p "$COMPOSE_DIR/searxng"
SEARXNG_SECRET=$(openssl rand -hex 32)
cat > "$COMPOSE_DIR/searxng/settings.yml" <<SEARXCFG
use_default_settings: true
server:
secret_key: "$SEARXNG_SECRET"
limiter: false
search:
formats:
- html
- json
SEARXCFG
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<SVC
searxng:
image: searxng/searxng:latest
container_name: searxng
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./searxng:/etc/searxng
environment:
SEARXNG_BASE_URL: http://localhost:8080/
networks:
- $DOCKER_NETWORK
SVC
;;
esac
done
# Add networks block
cat >> "$COMPOSE_DIR/docker-compose.yaml" <<FOOTER
networks:
$DOCKER_NETWORK:
external: true
FOOTER
# ─── start services ─────────────────────────────────────────────────────────
info "Starting docker compose services..."
(cd "$COMPOSE_DIR" && docker compose up -d) >&2
ok "Docker services: ${COMPOSE_SERVICES[*]}"
# ─── create database if PostgreSQL was selected ─────────────────────────────
if [[ -n "$PG_PASSWORD" ]]; then
info "Waiting for PostgreSQL to be ready..."
for i in $(seq 1 15); do
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
break
fi
sleep 1
done
if docker exec postgres pg_isready -U postgres >/dev/null 2>&1; then
# Create the database if it doesn't exist
docker exec postgres psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$PG_DATABASE'" | grep -q 1 \
|| docker exec postgres psql -U postgres -c "CREATE DATABASE $PG_DATABASE" >/dev/null 2>&1
ok "Database '$PG_DATABASE' ready"
else
warn "PostgreSQL not ready after 15s — create database manually"
fi
fi
# ─── output parseable values to stdout ───────────────────────────────────────
echo "COMPOSE_DIR=$COMPOSE_DIR"
if [[ " ${COMPOSE_SERVICES[*]} " == *" nginx-proxy-manager "* ]]; then
echo "NPM_BIND=$NPM_BIND"
fi
if [[ -n "$PG_PASSWORD" ]]; then
echo "POSTGRES_URL=postgresql://postgres:${PG_PASSWORD}@127.0.0.1:5432/${PG_DATABASE}"
fi
if [[ "$MAILHOG_SELECTED" == true ]]; then
echo "MAIL_TRANSPORT=smtp://127.0.0.1:1025"
fi
-258
View File
@@ -1,258 +0,0 @@
#!/bin/bash
# Officer — host dependencies for the optional, sidecar-backed features.
#
# Usage:
# bash scripts/setup/setup-sidecars.sh
#
# WHAT THIS IS
# Everything here was part of setup.sh and is not any more. setup.sh installs what the app needs to
# serve itself; this installs what a handful of *optional* features need on the host, and it is never
# invoked by setup.sh — running it is a deliberate act.
#
# The sections keep the numbering they had in setup.sh so the two files can be read against each
# other:
#
# 1 (was 8) Rust
# 2 (was 9) PulseAudio + audio dev headers
# 3 (was 10) cliamp
# 4 (was 13) yt-dlp
# 5 (was 17) Remote desktop (delegates to setup-desktop.sh)
#
# There is no `light`/`full` profile here. In setup.sh these sections were the ones `light` skipped,
# so gating them again would only mean "run this script and have it do nothing" — running it at all
# IS the opt-in.
#
# ORDER MATTERS: section 3 needs Go, which setup.sh installs. Run setup.sh first.
# Section 5 rewrites GRUB and switches the display manager — it takes effect on the next reboot.
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)"; }
has() { command -v "$1" &>/dev/null; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# setup.sh installed Go and rustup into the user's home and exported them for its own run only. A
# fresh shell has neither on PATH, which would make `has go` false (silently skipping the cliamp
# build) and `has rustc` false (re-running rustup over an existing toolchain). Put them back.
export PATH="$HOME/.local/go/bin:$HOME/.cargo/bin:$PATH"
# ─── 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 — optional host dependencies ($PM)"
echo "═══════════════════════════════════════════"
# ─── 1. Rust (was setup.sh section 8) ─────────────────────────────────────────
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
# ─── 2. PulseAudio (headless audio for cliamp) (was section 9) ────────────────
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
# ─── 3. cliamp (music player) (was section 10) ────────────────────────────────
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 (run setup.sh first)"
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
# ─── 4. yt-dlp (video/audio download) (was section 13) ────────────────────────
echo ""
echo "── yt-dlp ──"
# 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
# ─── 5. remote desktop (Ubuntu Desktop + VNC) (was section 17) ────────────────
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.
#
# This is by far the most expensive section — it pulls the whole ubuntu-desktop meta-package, rewrites
# /etc/default/grub and switches the display manager.
case $PM in
apt)
bash "$SCRIPT_DIR/setup-desktop.sh"
;;
*)
warn "Remote desktop setup is Ubuntu/Debian only — skipping"
;;
esac
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Verification"
echo "═══════════════════════════════════════════"
echo ""
check() {
if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi
}
echo "Rust:"
check rustc
check cargo
echo ""
echo "Audio (cliamp):"
check pulseaudio
check parec
check pactl
check cliamp
echo ""
echo "Download:"
check yt-dlp
echo ""
echo "═══════════════════════════════════════════"
echo " Done"
echo "═══════════════════════════════════════════"
echo ""
echo "Notes:"
echo " • PulseAudio null sink starts automatically with the server"
echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools"
echo " • Make sure ~/.local/go-path/bin is in your PATH for Go-installed tools (cliamp)"
echo " • REBOOT to switch into the GNOME-on-Xorg session the remote desktop mirrors"
echo ""
-906
View File
@@ -1,906 +0,0 @@
#!/bin/bash
# Officer — host dependency setup
# Run once on a fresh Ubuntu/Debian host before launching the server.
#
# Usage:
# bash scripts/setup/setup.sh # full server install
# OFFICER_PROFILE=light bash scripts/setup/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, and Go.
# 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.
#
# NOT INSTALLED HERE — and the gaps in the section numbers are where these used to be
# Moved to scripts/setup/setup-sidecars.sh, which nothing below invokes; run it deliberately, and
# only after this script: 8 Rust, 9 PulseAudio, 10 cliamp, 13 yt-dlp, 17 remote desktop.
#
# Removed outright, because the host provisioning already installs them and two installers racing
# for the same binaries is worse than one: 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit),
# 14 npm globals (the ~/.local npm prefix, Claude Code, pm2).
#
# That makes node, npm, pm2 and the agent CLIs PREREQUISITES of this script rather than products of
# it. Section 19 warns and skips rather than failing if pm2 is absent, so a host that never ran the
# provisioning will finish "successfully" with nothing listening — check the verification block.
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)"
# ../.. — this lives in scripts/setup/, so the repo root is two levels up, not one. Nothing here fails
# loudly if that is wrong: PROJECT_DIR is where .env is written, where `bun install` and `db:push` run and
# where pm2 is pointed, so an off-by-one level silently sets up scripts/ instead of the repo.
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
# 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, in setup-sidecars.sh)
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
# uidmap — newuidmap/newgidmap, needed for a member's own rootless Docker.
#
# Rootless containers map subordinate uid ranges, and those two setuid helpers are the only way to do it
# unprivileged. Without them `dockerd-rootless-setuptool.sh` fails at the first step. Core rather than a
# profile extra for the same reason acl is: the alternative to a member having their own daemon is adding
# them to the `docker` group, which is root on the host — see src/servers/os-user-docker.ts.
case $PM in
apt)
if dpkg -s uidmap &>/dev/null 2>&1; then skip "uidmap"; else CORE_PKGS+=(uidmap); fi
if dpkg -s dbus-user-session &>/dev/null 2>&1; then skip "dbus-user-session"; else CORE_PKGS+=(dbus-user-session); fi
;;
pacman)
if has newuidmap; then skip "uidmap (shadow)"; else CORE_PKGS+=(shadow); 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, which is why it survived the cull that removed the rest of the
# terminal tooling: oh-my-zsh, eza and lazygit are host comforts the provisioning installs, and the shell
# template treats each as optional. Starship it does not — the prompt would visibly degrade.
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. 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/setup/starship.toml ~/.config/ to take this one)"
fi
# Go is a host comfort rather than anything the app needs to serve a file browser, a terminal and a
# chat, so `light` skips it. It is the only section left in this block — 8-13 were removed or moved.
if is_light; then
echo ""
omit "Go"
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, 9 PulseAudio, 10 cliamp and 13 yt-dlp are in setup-sidecars.sh.
# 11 Neovim, 12 shell extras (oh-my-zsh/eza/lazygit) and 14 npm globals are gone entirely — the host
# provisioning owns node, npm, pm2, Claude Code, Neovim and the shell, and this script duplicating
# them meant two installers racing for the same binaries.
fi # end of the light-profile skip, which is now section 7 alone
# Kept from the removed section 14: nothing here installs into ~/.local/bin any more, but section 19
# still asks `has pm2` and the agent still resolves `claude` off PATH. A host that installed either
# user-locally would otherwise look like it has neither.
export PATH="$HOME/.local/bin:$PATH"
# ─── 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 is in setup-sidecars.sh.
# ─── 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 what this script still installs is checked. Reporting Go as NOT FOUND on a light install that
# deliberately skipped it makes a clean run look broken; so does checking for nvim, lazygit and eza,
# which this script no longer owns at all.
if ! is_light; then
check go
fi
check starship
check zsh
check rg
check fd
check jq
check htop
check tmux
check tree
check btop
check sqlite3
# Neither of these is installed here any more — they come from the host provisioning. They are still
# checked because section 19 and every chat turn depend on them, and "NOT FOUND" here is the only
# warning you get before the services silently do not start.
echo ""
echo "AI agents (from host provisioning):"
check claude
echo ""
echo "Process manager (from host provisioning):"
check pm2
echo ""
echo "Optional:"
check unzip
check 7z
check unrar
check pgrep
check fuser
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 " • Make sure ~/.local/go/bin and ~/.local/go-path/bin are in your PATH for Go tools"
echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications"
echo " • node, npm, pm2 and the agent CLIs come from the host provisioning, not from here"
echo " • Rust, PulseAudio, cliamp, yt-dlp and the remote desktop are NOT installed by this script:"
echo " run 'bash scripts/setup/setup-sidecars.sh' if you want them"
echo ""
-539
View File
@@ -1,539 +0,0 @@
#!/bin/bash
# Officer — macOS laptop setup.
#
# The barebones counterpart to scripts/setup/setup.sh (which targets an Ubuntu/Debian server and is left
# alone). This installs only what a laptop workflow needs: the file browser, Claude/opencode chat,
# and a terminal. No Go/Rust/cliamp/PulseAudio, no neovim, no shell dotfile stack, no VNC desktop,
# no sudoers grant, no power-management changes.
#
# EVERY STEP IS OPTIONAL. Each one prompts before doing anything, and can be preset non-interactively:
#
# SETUP_POSTGRES=0 SETUP_OPENCODE=0 bash scripts/setup/setup_mac_light.sh
#
# SETUP_PACKAGES brew node@22 / bun / ffmpeg SETUP_CLAUDE claude code CLI
# SETUP_POSTGRES brew postgresql@18 + createdb SETUP_OPENCODE opencode CLI
# SETUP_LINK_NODE brew link --force node@22 SETUP_DEPS bun install
# SETUP_PM2 pm2 via npm SETUP_ENV write .env
# SETUP_INIT gen:index + db:push SETUP_SERVICES pm2 startOrRestart
#
# Accepted values: 1/y/yes/true to run, anything else to skip. Unset = ask (or take the default when
# stdin is not a terminal).
#
# This script never calls sudo itself — everything lands under the Homebrew prefix or $HOME. Note
# that Homebrew's own installer does ask for an administrator password on a fresh Mac.
#
# Usage: bash scripts/setup/setup_mac_light.sh
set -euo pipefail
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
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"; }
step() { echo ""; echo -e "${BOLD}── $1 ──${NC}"; }
has() { command -v "$1" &>/dev/null; }
# `set -e` is on, so anything allowed to fail must be guarded explicitly — either inside an `if`, or
# with a trailing `|| true`. Note the classic trap this file avoids everywhere: a bare top-level
# `cmd_a && cmd_b` list returns non-zero when cmd_a fails, which aborts the script. Those are all
# written as `if cmd_a; then cmd_b; fi` instead.
FAILURES=()
note_failure() { FAILURES+=("$1"); fail "$1"; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ../.. — this lives in scripts/setup/. See the note in setup.sh: PROJECT_DIR is where .env is written
# and where bun install, gen:index, db:push and pm2 are pointed, and none of them fails loudly on the
# wrong directory.
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
PG_FORMULA="postgresql@18"
PG_DATABASE="officer_dev"
NODE_FORMULA="node@22"
ENV_FILE="$PROJECT_DIR/.env"
ECOSYSTEM="$PROJECT_DIR/ecosystem.mac.light.config.cjs"
# Ask, unless the matching SETUP_* variable already decided. $1 = variable name, $2 = prompt,
# $3 = default (y|n) used for a bare Enter and for non-interactive runs.
confirm() {
local var="$1" prompt="$2" default="$3" preset reply hint
preset="${!var:-}"
if [ -n "$preset" ]; then
case "$preset" in
1|y|Y|yes|YES|true) return 0 ;;
*) return 1 ;;
esac
fi
if [ ! -t 0 ]; then
if [ "$default" = "y" ]; then return 0; else return 1; fi
fi
hint="[y/N]"
if [ "$default" = "y" ]; then hint="[Y/n]"; fi
echo -en " ${BOLD}${prompt}${NC} ${hint}: "
reply=""
read -r reply || true
reply="${reply:-$default}"
case "$reply" in
y|Y|yes|YES) return 0 ;;
*) return 1 ;;
esac
}
# Prompt for a value with a default. Never fails the script.
ask() {
local __var="$1" prompt="$2" default="$3" reply=""
if [ ! -t 0 ]; then
printf -v "$__var" '%s' "$default"
return 0
fi
echo -en " ${BOLD}${prompt}${NC} [${default}]: "
read -r reply || true
printf -v "$__var" '%s' "${reply:-$default}"
}
echo ""
echo "═══════════════════════════════════════════"
echo " Officer — macOS setup"
echo "═══════════════════════════════════════════"
# ─── 0. preflight ─────────────────────────────────────────────────────────────
step "Preflight"
if [ "$(uname -s)" != "Darwin" ]; then
fail "This script is macOS-only. On Linux use scripts/setup/setup.sh."
exit 1
fi
# Running under sudo would install bun/claude/opencode into /var/root and write HOME_DIR=/var/root
# into .env. Nothing here needs root except the symlinks, which prompt individually.
if [ "$(id -u)" = "0" ] || [ -n "${SUDO_USER:-}" ]; then
fail "Do not run this under sudo — run it as your normal user."
exit 1
fi
ok "macOS $(sw_vers -productVersion), running as $(whoami)"
# Xcode Command Line Tools. Not needed to compile anything: the only two native modules, node-pty and
# argon2, both ship darwin-arm64/darwin-x64 prebuilds and fall back to node-gyp only when a prebuild
# is missing for the running arch. (node-pty has no Linux prebuild — that is why setup.sh needs
# build-essential and this one does not.)
#
# They still matter on a fresh Mac, because `git` comes from them and the file browser shells out to
# it. Installing Homebrew below pulls the CLT in, so this is a note, not a gate.
if xcode-select -p &>/dev/null; then
ok "Xcode command line tools ($(xcode-select -p))"
else
warn "Xcode command line tools not found — Homebrew's installer will pull them in"
echo " Standalone: xcode-select --install (also what a fresh Mac needs for git)"
fi
if has brew; then
ok "homebrew ($(brew --prefix))"
elif confirm SETUP_BREW "Homebrew is not installed. Install it?" y; then
if /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"; then
for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do
if [ -x "$candidate" ]; then
eval "$("$candidate" shellenv)" || true
break
fi
done
fi
if has brew; then ok "homebrew installed"; else note_failure "homebrew install failed"; fi
else
skip "homebrew (declined — brew-based steps will be skipped)"
fi
# macOS ships zip, unzip, curl, python3 and openssl (LibreSSL) in the base system — none are
# installed here.
for builtin_tool in zip unzip curl openssl nc; do
if ! has "$builtin_tool"; then warn "$builtin_tool not found (expected to ship with macOS)"; fi
done
# git needs its own check: /usr/bin/git is a stub on a Mac without the Command Line Tools, so
# `command -v git` succeeds while running it only pops the "install developer tools" dialog. The file
# browser shells out to git, so verify it actually executes. Invoking it here is deliberate — on a
# fresh Mac it triggers that install prompt early, rather than at first use inside the app.
if git --version &>/dev/null; then
ok "git ($(git --version 2>/dev/null || true))"
else
warn "git is not usable yet — accept the Command Line Tools prompt, then re-run this script"
fi
# ─── 1. homebrew packages ─────────────────────────────────────────────────────
step "Packages"
NODE_PREFIX=""
brew_install() {
local formula="$1" probe="$2"
if [ -n "$probe" ] && has "$probe"; then
skip "$formula (already present: $(command -v "$probe"))"
return 0
fi
if brew list --formula "$formula" &>/dev/null; then
skip "$formula (already installed)"
return 0
fi
echo " Installing $formula..."
if brew install "$formula"; then ok "$formula installed"; else note_failure "$formula install failed"; fi
}
if ! has brew; then
skip "packages (homebrew unavailable)"
elif confirm SETUP_PACKAGES "Install node@22, bun and ffmpeg via Homebrew?" y; then
# node@22 exactly — package.json's preinstall rejects anything else (`v < 22 || v > 22`), so plain
# `brew install node` (currently v24+) would break `bun install`.
brew_install "$NODE_FORMULA" ""
brew_install bun bun
# ffmpeg/ffprobe: the only external binaries the file browser shells out to besides git and zip.
brew_install ffmpeg ffmpeg
else
skip "packages (declined)"
fi
if has brew; then
NODE_PREFIX="$(brew --prefix "$NODE_FORMULA" 2>/dev/null || true)"
fi
# ─── 2. node on PATH ──────────────────────────────────────────────────────────
step "Node on PATH"
# node@22 is keg-only, so Homebrew does not put it on PATH. `brew link --force` symlinks it into the
# Homebrew prefix (user-owned, already on PATH) — no sudo, and nothing lands in /usr/local.
if ! has brew; then
skip "node link (homebrew unavailable)"
elif [ -z "$NODE_PREFIX" ] || [ ! -x "$NODE_PREFIX/bin/node" ]; then
skip "node link ($NODE_FORMULA not installed)"
elif [ "$(command -v node 2>/dev/null || true)" = "$NODE_PREFIX/bin/node" ]; then
skip "node already resolves to $NODE_FORMULA"
elif confirm SETUP_LINK_NODE "Link $NODE_FORMULA onto PATH (brew link --force)?" y; then
if brew link --force --overwrite "$NODE_FORMULA" >/dev/null 2>&1; then
ok "$NODE_FORMULA linked into $(brew --prefix)/bin"
else
note_failure "brew link $NODE_FORMULA failed — add $NODE_PREFIX/bin to PATH manually"
fi
else
skip "node link (declined)"
fi
# Verify node really is 22 — `bun install` fails its preinstall check otherwise.
if has node; then
NODE_MAJOR="$(node -v 2>/dev/null | cut -d. -f1 | sed 's/^v//' || true)"
if [ "$NODE_MAJOR" = "22" ]; then
ok "node $(node -v)"
else
note_failure "node $(node -v) is on PATH but package.json requires exactly v22"
fi
else
warn "node not on PATH"
fi
# ─── 3. postgres ──────────────────────────────────────────────────────────────
step "PostgreSQL"
# Detect a reachable server first — this machine may already run Postgres in Docker, in which case
# there is nothing to install and nothing to start.
PG_PREFIX=""
PG_RUNNING=0
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then
PG_RUNNING=1
ok "postgres already reachable on 127.0.0.1:5432 — nothing to install"
else
skip "no postgres on 127.0.0.1:5432"
if ! has brew; then
skip "postgres install (homebrew unavailable)"
# Default yes: nothing is listening, so there is no Docker/remote server to reuse and Homebrew is
# the only way this host gets a database.
elif confirm SETUP_POSTGRES "Install and start $PG_FORMULA via Homebrew?" y; then
brew_install "$PG_FORMULA" ""
PG_PREFIX="$(brew --prefix "$PG_FORMULA" 2>/dev/null || true)"
if [ -n "$PG_PREFIX" ] && [ -x "$PG_PREFIX/bin/pg_isready" ]; then
echo " Starting $PG_FORMULA..."
brew services start "$PG_FORMULA" >/dev/null 2>&1 || true
for _ in $(seq 1 20); do
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then break; fi
sleep 1
done
if "$PG_PREFIX/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then
PG_RUNNING=1
ok "postgres running"
# Homebrew's postgres trusts local connections for the current user, so no password needed.
if "$PG_PREFIX/bin/psql" -h 127.0.0.1 -lqt 2>/dev/null | cut -d'|' -f1 | grep -qw "$PG_DATABASE"; then
skip "database '$PG_DATABASE' exists"
elif "$PG_PREFIX/bin/createdb" -h 127.0.0.1 "$PG_DATABASE" 2>/dev/null; then
ok "database '$PG_DATABASE' created"
else
note_failure "could not create database '$PG_DATABASE'"
fi
else
note_failure "postgres did not become ready — try: brew services start $PG_FORMULA"
fi
fi
else
skip "postgres install (declined — supply a POSTGRES_URL below)"
fi
fi
# ─── 4. pm2 ───────────────────────────────────────────────────────────────────
step "pm2"
# User-local npm prefix so global installs never need sudo.
export PATH="$HOME/.local/bin:$PATH"
if has pm2; then
skip "pm2 ($(command -v pm2))"
elif ! has npm; then
skip "pm2 (npm unavailable)"
elif confirm SETUP_PM2 "Install pm2 (process manager for the server + sidecars)?" y; then
npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true
echo " Installing pm2..."
if npm install -g pm2 >/dev/null 2>&1; then ok "pm2 installed"; else note_failure "pm2 install failed"; fi
else
skip "pm2 (declined)"
fi
# ~/.local/bin is not on the macOS default PATH (see /etc/paths). It is exported for this run, but a
# later `pm2 logs` from a fresh shell needs it permanently.
case ":${PATH}:" in
*":$HOME/.local/bin:"*) ;;
*) warn "add ~/.local/bin to your PATH so pm2 and claude stay available in new shells" ;;
esac
# ─── 5. agents (claude, opencode) ─────────────────────────────────────────────
step "Agents"
if has claude; then
skip "claude ($(command -v claude))"
elif confirm SETUP_CLAUDE "Install Claude Code?" y; then
echo " Installing claude..."
if curl -fsSL https://claude.ai/install.sh | bash; then
if has claude; then ok "claude installed"; else note_failure "claude installed but not on PATH"; fi
else
note_failure "claude install failed"
fi
else
skip "claude (declined)"
fi
# No symlink needed: the claude sidecar resolves its CLI from CLAUDE_BIN, then PATH, then the
# installer's own locations (see resolveClaudeBin in sidecar/claude/claude-manager.ts).
if [ -x "$HOME/.opencode/bin/opencode" ]; then
skip "opencode ($HOME/.opencode/bin/opencode)"
elif confirm SETUP_OPENCODE "Install opencode?" y; then
echo " Installing opencode..."
if curl -fsSL https://opencode.ai/install | bash; then
if [ -x "$HOME/.opencode/bin/opencode" ]; then
ok "opencode installed"
else
note_failure "opencode installed but not at $HOME/.opencode/bin/opencode"
fi
else
note_failure "opencode install failed"
fi
else
skip "opencode (declined — officer-opencode will not start)"
fi
# ─── 6. project dependencies ──────────────────────────────────────────────────
step "Project dependencies"
if ! has bun; then
skip "bun install (bun unavailable)"
elif confirm SETUP_DEPS "Run bun install?" y; then
echo " Running bun install..."
if (cd "$PROJECT_DIR" && bun install); then
ok "dependencies installed"
else
note_failure "bun install failed"
fi
else
skip "bun install (declined)"
fi
# ─── 7. environment (.env) ────────────────────────────────────────────────────
step "Environment (.env)"
WRITE_ENV=0
if [ -f "$ENV_FILE" ]; then
if confirm SETUP_ENV "$ENV_FILE exists. Regenerate it?" n; then WRITE_ENV=1; else skip ".env (kept existing)"; fi
elif confirm SETUP_ENV "Generate .env?" y; then
WRITE_ENV=1
else
skip ".env (declined)"
fi
ENV_PUBLIC_URL=""
if [ "$WRITE_ENV" = "1" ]; then
echo ""
ask ENV_PORT "PORT" "9010"
# Plain http://localhost needs nothing relaxed to work: browsers treat it as a secure context, so
# passkeys, microphone capture and the clipboard are all available without TLS, and origin checking
# is already off by default (ALLOW_ANY_ORIGIN). Reaching this from another device is the case that
# needs an HTTPS proxy — http://192.168.x.x is not a secure context and those APIs fail there.
ask ENV_PUBLIC_URL "PUBLIC_URL" "http://localhost:$ENV_PORT"
ask ENV_DATA_PATH "DATA_PATH" "$HOME/.local/data"
ask ENV_ITEMS_DIR "OFFICER_ITEMS_DIR" "$(dirname "$PROJECT_DIR")/officer-items"
# A Docker postgres usually wants a password; brew's trusts the local user. Default to the plain
# local form and let it be edited — this is the one value the script cannot infer reliably.
ask ENV_POSTGRES_URL "POSTGRES_URL" "postgresql://$(whoami)@127.0.0.1:5432/$PG_DATABASE"
# jwt.ts throws at import time unless this is >= 32 chars, so verify rather than trust the pipeline.
JWT_SECRET="$(openssl rand -base64 48 | tr -d '/+=' | head -c 48 || true)"
if [ ${#JWT_SECRET} -lt 32 ]; then
note_failure "could not generate a JWT_SECRET (got ${#JWT_SECRET} chars) — .env not written"
else
mkdir -p "$ENV_DATA_PATH" "$ENV_ITEMS_DIR" || warn "could not create DATA_PATH/OFFICER_ITEMS_DIR"
if cat > "$ENV_FILE" <<ENVFILE
PORT="$ENV_PORT"
JWT_SECRET="$JWT_SECRET"
PUBLIC_URL="$ENV_PUBLIC_URL"
PUBLIC_BUILD_ENV="production"
DATA_PATH="$ENV_DATA_PATH"
OFFICER_ITEMS_DIR="$ENV_ITEMS_DIR"
HOME_DIR="$HOME"
POSTGRES_URL="$ENV_POSTGRES_URL"
ENVFILE
then
# Holds the JWT signing secret and the database URL.
chmod 600 "$ENV_FILE" || warn "could not chmod 600 $ENV_FILE"
ok ".env written to $ENV_FILE (mode 600)"
else
note_failure "could not write $ENV_FILE"
fi
fi
fi
# ─── 8. project initialization ────────────────────────────────────────────────
step "Project initialization"
if ! has bun || [ ! -f "$ENV_FILE" ]; then
skip "initialization (bun or .env missing)"
elif confirm SETUP_INIT "Generate index.gen.html and apply the database schema?" y; then
# index.gen.html is gitignored and built from PUBLIC_URL, so it never exists on a fresh clone.
echo " Generating index.gen.html..."
if (cd "$PROJECT_DIR" && bun run gen:index); then
ok "index.gen.html generated"
else
note_failure "gen:index failed — the app will not serve until this succeeds"
fi
# Officer applies its schema with push; there are no migrations.
echo " Applying database schema..."
if (cd "$PROJECT_DIR" && bun db:push); then
ok "schema applied"
else
note_failure "db:push failed — check POSTGRES_URL and that postgres is reachable"
fi
else
skip "initialization (declined)"
fi
# ─── 9. services ──────────────────────────────────────────────────────────────
step "Services (pm2)"
if ! has pm2 || [ ! -f "$ECOSYSTEM" ]; then
skip "services (pm2 or ecosystem.mac.light.config.cjs missing)"
elif [ ! -f "$ENV_FILE" ]; then
# Starting without .env gives a server on port 5000 with no database and a JWT_SECRET throw.
skip "services (.env missing — they would crash-loop)"
elif confirm SETUP_SERVICES "Start Officer and its sidecars with pm2?" y; then
echo " Starting services..."
if (cd "$PROJECT_DIR" && pm2 startOrRestart "$ECOSYSTEM"); then
ok "services started"
else
note_failure "pm2 could not start the services — check 'pm2 logs'"
fi
if pm2 save >/dev/null 2>&1; then ok "process list saved"; else warn "pm2 save failed"; fi
# `pm2 save` on its own writes a process list that nothing ever reads. The Linux setup pairs it with
# `pm2 startup`, which installs the boot unit that resurrects that list; without the pair, a reboot
# silently leaves the machine with nothing running. On macOS the equivalent is a launchd agent rather
# than a systemd unit. Kept optional because a laptop is not a server — you may not want the whole
# stack coming back at every login — and non-fatal, because pm2's launchd integration can want an
# elevated prompt that a scripted run should not force.
if confirm SETUP_BOOT "Start Officer automatically at login (pm2 + launchd)?" y; then
if pm2 startup launchd -u "$(whoami)" --hp "$HOME" >/dev/null 2>&1; then
pm2 save >/dev/null 2>&1
ok "services will start at login"
else
warn "Could not install the launchd agent — run: pm2 startup (and follow its instructions)"
fi
else
skip "login startup (declined — 'pm2 startup' enables it later)"
fi
else
skip "services (declined)"
fi
# ─── verification ─────────────────────────────────────────────────────────────
echo ""
echo "═══════════════════════════════════════════"
echo " Verification"
echo "═══════════════════════════════════════════"
echo ""
check() { if has "$1"; then ok "$1"; else fail "$1 — NOT FOUND"; fi; }
echo "Required:"
check node
check npm
check bun
check git
check zip
check ffmpeg
check ffprobe
echo ""
echo "Database:"
if nc -z -G 2 127.0.0.1 5432 &>/dev/null; then ok "postgres reachable on 127.0.0.1:5432"; else fail "postgres — NOT REACHABLE on 127.0.0.1:5432"; fi
echo ""
echo "Agents:"
check claude
if [ -x "$HOME/.opencode/bin/opencode" ]; then ok "opencode"; else fail "opencode — NOT FOUND"; fi
echo ""
echo "Process manager:"
check pm2
if has pm2 && [ -f "$ECOSYSTEM" ]; then
echo ""
echo "Services:"
# Read the names with node rather than grepping for `name:`. The profile derives its apps from
# ecosystem.config.cjs and has no literal name keys to match, so a grep silently lists nothing —
# which looks identical to "no services configured". Loading it also exercises the profile's own
# consistency checks, which is exactly the moment you want to hear about a drifted include list.
ECOSYSTEM_APPS=$(node -e "require('$ECOSYSTEM').apps.forEach(a=>console.log(a.name))" 2>/dev/null) \
|| fail "$(basename "$ECOSYSTEM") could not be loaded — run: node -e \"require('./$(basename "$ECOSYSTEM")')\" to see why"
for app in $ECOSYSTEM_APPS; do
if [ -n "$(pm2 pid "$app" 2>/dev/null | tr -d '[:space:]' || true)" ]; then
ok "$app"
else
fail "$app — not running (pm2 logs $app)"
fi
done
fi
echo ""
echo "═══════════════════════════════════════════"
if [ ${#FAILURES[@]} -eq 0 ]; then
echo -e " ${GREEN}Setup complete${NC}"
else
echo -e " ${YELLOW}Setup finished with ${#FAILURES[@]} problem(s)${NC}"
for f in "${FAILURES[@]}"; do echo "$f"; done
fi
echo "═══════════════════════════════════════════"
echo ""
echo "Notes:"
echo " • Open ${ENV_PUBLIC_URL:-http://localhost:9010} — the first-run screen creates the owner account"
echo " • Logs: pm2 logs Restart: pm2 restart ecosystem.mac.light.config.cjs"
echo " • Postgres must be running before the services start, or db:push and boot will fail"
echo " • Not run on macOS: VNC desktop, email sync, music indexer, cliamp audio, and the sidecars"
echo " that front a container or an external service — vault, slskd, headscale, transmission,"
echo " invoiceshelf, memos, photos, caldav, notify, wallet. See ecosystem.mac.light.config.cjs."
echo " • Pin a specific Claude CLI with CLAUDE_BIN=/path/to/claude in .env if you need to"
echo " • Re-run any single step with e.g. SETUP_OPENCODE=1 bash scripts/setup/setup_mac_light.sh"
echo ""