Files
platform/scripts/setup/officer-setup.sh
T
pastilhasandClaude Opus 5 3f071c0b24 the install root is derived, not configured
Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone
from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and
VAULT_STORE_KEY are no longer written by the setup script.

data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/,
capabilities/ and dockers/ as fixed names under it. The direction used to run the
other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in
app-store/paths.ts — which meant three environment variables that had to agree
with each other and with the tree on disk.

Eight files re-read process.env.DATA_PATH independently, each with its own
`?? cwd()/data` fallback. They import the one value now, which is what made
removing it safe: otherwise each would have derived its own and drifted.

Three things this turned up.

The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a
comment asserting "__dirname is the repo root — this file sits beside
ecosystem.config.cjs", which stopped being true when these files moved into
ecosystem-files/. It walks up to the platform's package.json now, which holds
wherever the file lives. That was a live bug before this change and a load-bearing
one after it, since cwd now decides where the install is.

assertInstallLayout joins the other two boot assertions. A wrong cwd does not
error — it computes a plausible root somewhere else and writes managed homes and
agent runs into it, so the install looks empty and the data looks lost with
nothing naming the cause. It throws before serve(), first of the three, because a
wrong answer there makes the other two check the wrong files.

getOwnerHomeDir captures homedir() once at module load rather than per call.
Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME
when set rather than reading passwd, and user-instance.ts assigns process.env.HOME
on its way to spawning an agent. A lazy read would have returned the owner's home
on the first call and a member's afterwards. data-path.ts imports only node
builtins, so it is evaluated before any of that runs.

JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script
does not boot — jwt.ts throws at module load without one. That is the agreed
sequencing: they move to the SQLite store (docs/secret-store.md), and writing them
here meanwhile would create a second origin for a secret the store then has to be
reconciled with. Said plainly in .env.example and in lib/env.sh rather than left
to be discovered.

Not typechecked: node_modules is empty here and installs are frozen. Every edited
file parses under `bun build --no-bundle`; the profile loads and pins the right
cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup
section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx
is not the pinned resolution and reformatted unrelated unions and line wraps in
six files, so those were reverted and the edits re-applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:18:48 +00:00

526 lines
20 KiB
Bash
Executable File

#!/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.
#
# Run as root: sudo scripts/setup/officer-setup.sh
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROGRESS_FILE="$SCRIPT_DIR/officer-setup/.setup-progress"
ONLY_STEP=""
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_STEP="${2:-}"
shift 2
;;
--only=*)
ONLY_STEP="${1#*=}"
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list]"
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
esac
done
# shellcheck source=officer-setup/lib/base.sh
source "$SCRIPT_DIR/officer-setup/lib/base.sh"
# shellcheck source=officer-setup/lib/preflight.sh
source "$SCRIPT_DIR/officer-setup/lib/preflight.sh"
# shellcheck source=officer-setup/lib/repo.sh
source "$SCRIPT_DIR/officer-setup/lib/repo.sh"
# shellcheck source=officer-setup/lib/layout.sh
source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
# shellcheck source=officer-setup/lib/postgres.sh
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
# shellcheck source=officer-setup/lib/env.sh
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
# =============================================================================
# 1. Pre-flight
# =============================================================================
echo ""
echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}║ Officer Setup ║${NC}"
echo -e "${BOLD}╚══════════════════════════════════════════════════╝${NC}"
if [[ "$EUID" -ne 0 ]]; then
fail "Please run as root: sudo ./officer-setup.sh"
fi
# ── what machine-setup already established ──
echo ""
if load_machine_answers; then
info "Read from machine-setup: ${MACHINE_ANSWERS}"
else
warn "machine-setup has not run on this machine"
echo " That is fine if you provisioned it another way — the questions it"
echo " would have answered are asked below instead."
fi
# ── the account ──
#
# A remembered answer can go stale: the account it names may have been renamed or
# removed since machine-setup ran. That is a reason to ask again, not a reason to
# stop — so the remembered value is checked before it is trusted, and a bad one
# is reported and replaced rather than ending the run.
if [[ -n "$USERNAME" ]] && ! owner_exists; then
warn "the remembered account '${USERNAME}' does not exist on this machine any more"
USERNAME=""
fi
while [[ -z "$USERNAME" ]] || ! owner_exists; do
echo ""
info "Which account owns this Officer install?"
echo " Its files, its node_modules and its pm2 process list all belong to"
echo " this account rather than to root."
echo ""
ask_required USERNAME "Username" "${SUDO_USER:-}"
owner_exists || warn "There is no account called '${USERNAME}' on this machine."
done
resolve_user_home
# ── where it goes ──
if [[ -z "$OFFICER_ROOT" ]]; then
echo ""
info "Where should Officer be installed?"
echo " One directory holding the app, its data, the item store and any"
echo " containers the app store provisions."
echo ""
ask_required OFFICER_ROOT "Path" "${USER_HOME}/officerdev"
fi
OFFICER_ROOT="${OFFICER_ROOT/#\~/$USER_HOME}"
[[ "$OFFICER_ROOT" == /* ]] || fail "That needs to be an absolute path — got '${OFFICER_ROOT}'"
OFFICER_ROOT="${OFFICER_ROOT%/}"
info "Account: ${USERNAME} (home ${USER_HOME})"
info "Officer: ${OFFICER_ROOT}"
[[ -n "$MACHINE_ROLE" ]] && info "Role: ${MACHINE_ROLE}"
# ── is the machine actually ready ──
#
# Checked and reported together. Finding out about a missing bun three sections
# in, after a repository has been cloned and a database started, is a worse way
# to learn it.
echo ""
info "What Officer needs from this machine"
mapfile -t MISSING < <(missing_tools)
mapfile -t MISSING_OPT < <(missing_optional_tools)
for t in "${REQUIRED_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "MISSING" "$(tool_why "$t")"
fi
done
for t in "${OPTIONAL_TOOLS[@]}"; do
if command -v "$t" &>/dev/null; then
printf ' %-6s %-10s %s\n' "$t" "ok" "$(tool_why "$t")"
else
printf ' %-6s %-10s %s\n' "$t" "absent" "$(tool_why "$t") — optional"
fi
done
if ((${#MISSING[@]} > 0)); then
echo ""
fail "Missing: ${MISSING[*]}. Run scripts/setup/machine-setup/machine-setup.sh first, or install them yourself."
fi
if ((${#MISSING_OPT[@]} > 0)); then
echo ""
warn "No Docker. Postgres will have to be one you already run, and the app"
echo " store cannot provision anything until Docker is installed."
fi
if [[ -f "$PROGRESS_FILE" ]]; then
echo ""
info "Resuming — $(wc -l <"$PROGRESS_FILE") step(s) already done, and they will be skipped"
echo " To start over instead: sudo rm ${PROGRESS_FILE}"
else
echo ""
echo " This can be stopped at any point and run again later. Completed"
echo " steps are remembered and skipped."
fi
# =============================================================================
# 2. Layout
# =============================================================================
#
# Before the repository, because the repository is cloned into it.
step "Layout"
if ! skip; then
echo ""
info "Layout — everything Officer owns, under one root"
echo " ${OFFICER_ROOT}/"
echo " platform/ the app"
echo " data/ managed homes, attachments, job logs"
echo " dockers/ anything the app store provisions"
echo " capabilities/ skills, tools, tasks, processes"
echo ""
echo " Nothing here is configurable. The original asked separately for the"
echo " data directory and the item store, which were two answers that had"
echo " to agree with each other. One root now, and the rest follows."
echo ""
echo " To put data/ on a bigger volume later, symlink it — that is a"
echo " decision about storage rather than about how Officer is laid out."
mapfile -t WRONG_OWNER < <(layout_wrong_owner)
if ((${#WRONG_OWNER[@]} > 0)); then
echo ""
warn "these exist but do not belong to ${USERNAME}:"
printf ' %s\n' "${WRONG_OWNER[@]}"
echo " Everything that writes into them runs as ${USERNAME} — the platform"
echo " under pm2, the app store's compose files, the item store the agent"
echo " authors into. Left as they are, those writes fail in a way that"
echo " reads as a bug in the platform."
if confirm "Give them to ${USERNAME}?"; then
for d in "${WRONG_OWNER[@]}"; do chown -R "${USERNAME}:$(user_group)" "$d"; done
ok "ownership corrected"
SUMMARY+=("Layout: ownership corrected on ${#WRONG_OWNER[@]} directory(ies)")
fi
fi
create_layout
ok "layout in place under ${OFFICER_ROOT}"
SUMMARY+=("Layout: ${OFFICER_ROOT} (data, dockers, capabilities)")
step_ok
fi
# =============================================================================
# 3. Repository
# =============================================================================
step "Repository"
if ! skip; then
PLATFORM_DIR="$(platform_dir)"
echo ""
info "Repository — where the platform's code lives"
echo " path: ${PLATFORM_DIR}"
if repo_exists; then
echo " remote: $(repo_remote)"
echo " branch: $(repo_branch)"
echo " working: $(repo_is_dirty && echo 'has uncommitted changes' || echo 'clean')"
# Reported, never silently corrected. Repointing somebody's remote is a
# decision about where their work goes, and this script is not entitled to
# make it quietly.
if [[ -n "$(repo_remote)" && "$(repo_remote)" != "$OFFICER_REPO" ]]; then
echo ""
warn "this checkout points somewhere other than ${OFFICER_REPO}"
echo " Left alone. To move it:"
echo " git -C ${PLATFORM_DIR} remote set-url origin ${OFFICER_REPO}"
fi
if repo_is_dirty; then
echo ""
echo " not pulling — there are uncommitted changes here, and a pull"
echo " would either fail or bury them"
SUMMARY+=("Repository: present at ${PLATFORM_DIR}, left alone (uncommitted changes)")
elif confirm "Pull the latest changes?"; then
if pull_repo; then
ok "up to date on $(repo_branch)"
SUMMARY+=("Repository: pulled, on $(repo_branch)")
else
# --ff-only, so this means the branch has diverged rather than that the
# network failed. Saying which matters.
warn "could not fast-forward — the local branch has diverged from the remote"
ERRORS+=("Repository: pull refused, branch diverged")
SUMMARY+=("Repository: present, pull refused (diverged)")
fi
else
SUMMARY+=("Repository: present at ${PLATFORM_DIR}")
fi
else
echo " nothing there yet"
echo ""
info "Clone from ${OFFICER_REPO}?"
echo " Cloned as ${USERNAME}, not as root — a repository owned by root is"
echo " one you cannot pull, commit in, or install into."
CLONE_URL="$OFFICER_REPO"
if confirm "Clone it now?"; then
if clone_repo "$CLONE_URL"; then
ok "cloned to ${PLATFORM_DIR} on $(repo_branch)"
SUMMARY+=("Repository: cloned from ${CLONE_URL}")
else
# GIT_TERMINAL_PROMPT=0 in clone_repo means this is a real failure rather
# than a prompt nobody answered.
fail "could not clone ${CLONE_URL} — nothing below can run without it."
fi
else
fail "Nothing below can run without the repository."
fi
fi
step_ok
fi
# =============================================================================
# 4. Dependencies
# =============================================================================
step "Dependencies"
if ! skip; then
echo ""
info "Dependencies — bun install, as ${USERNAME}"
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
echo ""
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
echo " so a package.json that disagrees with it fails rather than quietly"
echo " picking newer versions. That friction is deliberate."
echo ""
echo " node-pty has no Linux prebuild, so this compiles it from source"
echo " every time — which is what build-essential and python3 are for."
if deps_installed && node_pty_built; then
ok "already installed, and node-pty is built"
SUMMARY+=("Dependencies: already installed")
elif confirm "Install them?"; then
if install_deps; then
if node_pty_built; then
ok "installed, node-pty built"
SUMMARY+=("Dependencies: installed")
else
# The install can succeed while the native module does not get built —
# bun skips a dependency's lifecycle scripts unless it trusts the
# package. Worth naming, because the symptom is a terminal that never
# comes up rather than an install error.
warn "installed, but node-pty has no built module at node_modules/node-pty/build/Release/"
echo " The terminal sidecar cannot start without it. Try:"
echo " cd $(platform_dir) && bun install --force"
ERRORS+=("Dependencies: node-pty not built")
SUMMARY+=("Dependencies: installed, node-pty NOT built")
fi
else
warn "bun install failed"
echo " If it complained about the lockfile, package.json and bun.lock"
echo " disagree — that is the frozen lockfile doing its job, and it"
echo " wants a human to look at the diff."
ERRORS+=("Dependencies: bun install failed")
SUMMARY+=("Dependencies: FAILED")
fi
else
warn "skipped by request"
SUMMARY+=("Dependencies: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 5. Database
# =============================================================================
#
# POSTGRES_URL is set here and written by the environment section below.
step "Database"
if ! skip; then
echo ""
info "Database — Postgres, the only one Officer has"
echo " It holds the account, passkeys, settings, dashboards, email"
echo " accounts and the job queue. Nothing else in the platform is a"
echo " database."
echo ""
# One network for everything Officer provisions. Created before the compose
# file references it, since it is declared external there.
if ensure_docker_network; then
ok "docker network '${OFFICER_NETWORK}' created"
SUMMARY+=("Docker network: ${OFFICER_NETWORK} created")
elif docker_network_exists; then
echo " network: ${OFFICER_NETWORK} (already there)"
fi
echo " compose file: $(pg_compose_exists && echo "$(pg_compose_file)" || echo 'not written yet')"
echo " container: $(pg_container_running && echo "${PG_CONTAINER} running" || echo 'not running')"
echo " port ${PG_PORT}: $(pg_port_in_use && echo 'something is listening' || echo 'free')"
POSTGRES_URL=""
# An existing compose file means this ran before. Reuse its password rather
# than minting a new one, which would leave the container and the URL
# disagreeing about the credential.
if pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"; then
POSTGRES_URL="$(pg_url "$PG_EXISTING_PASSWORD")"
echo ""
echo " already provisioned here — reusing the password from $(pg_env_file)"
pg_container_running || {
info " starting it"
pg_compose_up >/dev/null 2>&1 || true
}
if pg_wait_ready; then
ok "postgres answering on 127.0.0.1:${PG_PORT}"
SUMMARY+=("Database: existing Postgres at ${PG_CONTAINER}")
else
warn "the container is not answering — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: provisioned but not answering")
fi
else
echo ""
info "Which Postgres should Officer use?"
echo ""
echo " [1] provision one here"
echo " ${PG_IMAGE} in $(pg_service_dir), bound to 127.0.0.1 only."
echo " Docker publishes ports by writing iptables rules beneath ufw,"
echo " so a database published to every interface is reachable from"
echo " the internet whatever the firewall says. Loopback is all the"
echo " platform needs — it runs on this machine."
echo ""
echo " [2] use one you already run"
echo " Give the connection URL. Nothing is provisioned."
echo ""
DB_PICK=""
while [[ -z "$DB_PICK" ]]; do
if ! read -rp " Which one? (1/2) [1]: " DB_CHOICE; then
echo ""
fail "No answer."
fi
case "${DB_CHOICE:-1}" in
1)
if ! command -v docker &>/dev/null; then
warn "Docker is not installed, so there is nothing to provision into."
continue
fi
if pg_port_in_use; then
warn "something is already listening on ${PG_PORT} — provisioning here would fail to bind"
echo " If that is a Postgres you already run, pick 2 and give its URL."
continue
fi
DB_PICK=provision
;;
2) DB_PICK=existing ;;
*) warn "Pick 1 or 2." ;;
esac
done
if [[ "$DB_PICK" == provision ]]; then
PG_PASSWORD="$(openssl rand -base64 32 | tr -d '/+=' | head -c 32)"
write_pg_compose "$PG_PASSWORD"
ok "compose written to $(pg_compose_file)"
if pg_compose_up && pg_wait_ready; then
POSTGRES_URL="$(pg_url "$PG_PASSWORD")"
ok "postgres answering on 127.0.0.1:${PG_PORT}, database '${PG_DATABASE}'"
SUMMARY+=("Database: provisioned at $(pg_service_dir)")
else
warn "the container did not come up — check: docker logs ${PG_CONTAINER}"
ERRORS+=("Database: container did not start")
SUMMARY+=("Database: provisioning FAILED")
fi
else
echo ""
ask_required POSTGRES_URL "Connection URL" "postgresql://user:password@host:5432/officer"
if pg_url_works "$POSTGRES_URL"; then
ok "reachable"
SUMMARY+=("Database: existing, ${POSTGRES_URL%%:*}://…")
else
# Not fatal. The URL may be right and the database not started yet, and
# refusing to continue over that would be worse than saying so.
warn "could not connect with that URL"
echo " Kept anyway — check it before running the schema step."
ERRORS+=("Database: the given URL did not answer")
SUMMARY+=("Database: existing URL kept, did not answer")
fi
fi
fi
step_ok
fi
# =============================================================================
# 6. Environment
# =============================================================================
step "Environment"
if ! skip; then
echo ""
info "Environment — $(env_file)"
# Read back before anything is asked; existing values become the defaults.
ENV_PORT="$(env_get PORT)"
ENV_DISCORD_WEBHOOK="$(env_get DISCORD_BUG_REPORT_WEBHOOK)"
ENV_BROWSER_RELAY_PORT="$(env_get BROWSER_RELAY_PORT)"
if env_exists; then
echo " exists — its values are the defaults below"
else
echo " does not exist yet"
fi
# ── what is asked ──
echo ""
ask_required ENV_PORT "Port Officer listens on" "${ENV_PORT:-9000}"
ENV_BROWSER_RELAY_PORT="${ENV_BROWSER_RELAY_PORT:-18792}"
# ── origin checking, decided by the machine rather than by a default ──
#
# ALLOW_ANY_ORIGIN defaults to ON inside the platform, which CLAUDE.md says is
# only defensible because the tailnet is the perimeter. So the value is written
# explicitly here, from whether this machine actually has one.
if tailnet_present; then
ENV_ALLOW_ANY_ORIGIN="true"
ORIGIN_WHY="tailscale0 is up, so the tailnet is the perimeter"
else
ENV_ALLOW_ANY_ORIGIN="false"
ORIGIN_WHY="no tailnet on this machine, so origin checking is left ON"
fi
echo ""
echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}${ORIGIN_WHY}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT} BROWSER_RELAY_PORT=${ENV_BROWSER_RELAY_PORT}"
echo " ALLOW_ANY_ORIGIN=${ENV_ALLOW_ANY_ORIGIN}"
echo " POSTGRES_URL=${POSTGRES_URL%%:*}://…"
echo ""
echo " the install root is not written here — the platform derives it as the"
echo " parent of the repo, so data/, capabilities/ and dockers/ follow from"
echo " ${OFFICER_ROOT} without anything having to agree with anything."
echo ""
if confirm "Write it?"; then
write_env
ok "written, 0600, owned by ${USERNAME}"
[[ -f "$(env_file).before-officer-setup" ]] && echo " previous kept as $(env_file).before-officer-setup"
SUMMARY+=("Environment: $(env_file)")
else
warn "skipped by request"
SUMMARY+=("Environment: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# NOT BUILT YET
# =============================================================================
# 6 Schema db:push
# 7 Build gen:index
# 8 Services pm2 startOrRestart · save · startup
# 9 Verify are the processes actually up
echo ""
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
echo ""