Files
pastilhasandClaude Opus 5 11710f283a wire the reverse proxy in as officer-setup 12
~/npm-setup-draft/setup-npm.sh, adapted to the script's own helpers and placed last
— it is the only step that needs Officer already running.

It ignores --unattended, as asked. Every other question in this script has a
defensible default; a domain name, a DNS provider and that provider's API
credentials do not, and the step is opt-in besides. Its prompts read stdin directly
instead of going through confirm()/ask_required(), and they are NAMED APART
(proxy_confirm, proxy_ask) so nobody later consolidates them into the shared helpers
and quietly makes --unattended agree to publishing a public hostname.

The valve is a TTY check rather than the flag: with no terminal there is nobody to
ask, so it skips and prints the manual instructions. A cron-driven install still
works.

Five fixes to the draft:

  - `${OFFICER_REPO}/scripts/store-npm-credential.ts` — OFFICER_REPO is a git URL,
    not a directory, so that path was https://…/platform.git/scripts/… and the -f
    test could never pass. The whole persist-to-platform branch was dead code
    falling through to the print. Dropped it: the comment beside it already argued
    that not storing this password is a legitimate outcome, since only a human
    logging into the admin UI needs it.
  - NOT re-runnable, despite saying so. claim_admin returned early on an already
    claimed instance without setting NPM_EMAIL/NPM_PASSWORD, and get_token
    dereferenced both under set -u. Second run died on an unbound variable. It now
    asks for the existing credentials.
  - $HOME/dockers → $OFFICER_ROOT/dockers, matching data-path.ts. And the network
    is SETUP_DOCKER_NETWORK (`services`), not a second bridge called `officerdev`.
  - dig → getent hosts. dnsutils is not installed by this platform, so the check was
    command-not-found on a fresh VPS — and an empty answer is indistinguishable from
    "not resolving yet", so it waited the full 30 minutes before failing.
  - python3 → jq for host-side JSON. jq is already in the core package list; the one
    remaining python3 runs INSIDE the NPM container to read its own credential
    template, which is the point of reading it from there.

Failure is contained: every function warns and returns non-zero rather than exiting,
so a proxy that does not come up leaves a finished Officer install behind. Retry
with `--only Proxy`.

Verified: bash -n, shellcheck -S warning clean, --list shows Proxy, all seven
external commands present, and the jq filters checked against sample payloads
including the multi-line DNS credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:56:40 +00:00

961 lines
39 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=""
# Kept before the loop consumes them. This script re-executes itself through sudo
# further down and was passing `"$@"`, which `shift` had already emptied — so
# `officer-setup.sh --only build` run as a normal user silently became a FULL run
# the moment it escalated. Nothing said so; the flag just stopped existing.
ORIGINAL_ARGS=(${@+"$@"})
while [[ $# -gt 0 ]]; do
case "$1" in
--only)
ONLY_STEP="${2:-}"
shift 2
;;
--only=*)
ONLY_STEP="${1#*=}"
shift
;;
# Set before lib/repo.sh is sourced below, which reads it as
# `${OFFICER_REPO:-<default>}` — so this wins and an absent flag still defaults.
--repo)
[[ -n "${2:-}" ]] || {
echo "--repo needs a URL" >&2
exit 2
}
OFFICER_REPO="$2"
shift 2
;;
--repo=*)
OFFICER_REPO="${1#*=}"
shift
;;
--unattended | -y)
export UNATTENDED=1 ASSUME_YES=1
shift
;;
-l | --list)
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
exit 0
;;
-h | --help)
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>] [--unattended]"
echo ""
echo " --only <step> run one step; --list names them"
echo " --unattended take the default for every question that has one (-y)"
echo " --repo <url> clone from here instead of the default, which is a"
echo " private Gitea over SSH and only authenticates on a"
echo " machine whose key it already knows. Same as exporting"
echo " OFFICER_REPO. Ignored once the repo is checked out."
exit 0
;;
*) echo "unknown option: $1" >&2 && exit 2 ;;
esac
done
export OFFICER_REPO="${OFFICER_REPO:-}"
# shellcheck source=officer-setup/lib/base.sh
source "$SCRIPT_DIR/report.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"
# shellcheck source=officer-setup/lib/secrets.sh
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
# shellcheck source=officer-setup/lib/build.sh
source "$SCRIPT_DIR/officer-setup/lib/build.sh"
# shellcheck source=officer-setup/lib/services.sh
source "$SCRIPT_DIR/officer-setup/lib/services.sh"
# shellcheck source=officer-setup/lib/proxy.sh
source "$SCRIPT_DIR/officer-setup/lib/proxy.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}"
# ── Privileges: asked for, not demanded ──
#
# Run this as YOURSELF. It needs root on Linux, so it asks through sudo and
# re-executes itself rather than making you type it. Variables are passed to sudo
# by name rather than with -E, because `env_reset` is the sudoers default and
# strips the environment — which is how DATA_PATH was lost once already.
#
# macOS never escalates: Homebrew refuses to run as root, and the account running
# this IS the owner, so there is nothing to chown and nothing to drop to.
if [[ "$(uname -s)" == "Darwin" ]]; then
if [[ "$EUID" -eq 0 ]]; then
fail "Do not run this with sudo on macOS — run it as yourself."
fi
elif [[ "$EUID" -ne 0 ]]; then
command -v sudo >/dev/null 2>&1 || fail "This needs root and sudo is not installed — run it as root."
echo ""
echo " This needs administrator rights. You will be asked for your password."
echo ""
exec sudo \
OFFICER_ROOT="${OFFICER_ROOT:-}" \
SETUP_USERNAME="${SETUP_USERNAME:-}" \
MACHINE_ROLE="${MACHINE_ROLE:-}" \
REPORT_FILE="${REPORT_FILE:-}" \
UNATTENDED="${UNATTENDED:-}" \
ASSUME_YES="${ASSUME_YES:-}" \
OFFICER_REPO="${OFFICER_REPO:-}" \
bash "$SCRIPT_DIR/officer-setup.sh" ${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}
fi
trap report_flush EXIT
# ── 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}"
# ── recover what earlier runs already decided ──
#
# A skipped section leaves its variables unset, and later sections read them. On
# a resume that is every section before the one it stopped at, so Build announced
# "PUBLIC_URL <not set — run the Environment section first>" on a machine whose
# .env had been written twenty minutes earlier.
#
# Read back here, once, from the file that already holds the answers, rather than
# per-section — three variables cross a section boundary (ENV_PORT and
# ENV_PUBLIC_URL from Environment, POSTGRES_URL from Database) and the next one
# added would have to remember to do this again.
#
# Only fills what is EMPTY, so a variable passed in on the command line still
# wins, and a section that runs for real still overwrites it with its own answer.
if [[ -f "$(env_file)" ]]; then
ENV_PORT="${ENV_PORT:-$(env_get PORT)}"
ENV_PUBLIC_URL="${ENV_PUBLIC_URL:-$(env_get PUBLIC_URL)}"
POSTGRES_URL="${POSTGRES_URL:-$(env_get POSTGRES_URL)}"
fi
# ── 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.
report_section "Layout"
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
# =============================================================================
report_section "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
# =============================================================================
report_section "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.
report_section "Database"
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
# =============================================================================
report_section "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_PUBLIC_URL="$(env_get PUBLIC_URL)"
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}"
echo ""
echo " PUBLIC_URL is where Officer is reached from a browser. It is the one"
echo " thing this machine cannot work out for itself, and three things need"
echo " it: the OpenGraph tags baked into the page by 'bun gen:index', the"
echo " host the task API hands to scripts, and the CalDAV profile an iPhone"
echo " installs — that last one requires https."
echo ""
echo " Defaulting to this machine's tailnet address, not localhost: the"
echo " tailnet is where Officer is actually reached from, and localhost"
echo " works from here and nowhere else."
ask_required ENV_PUBLIC_URL "Public URL" "${ENV_PUBLIC_URL:-$(default_public_url "$ENV_PORT")}"
echo ""
echo " to write:"
echo " PORT=${ENV_PORT}"
echo " PUBLIC_URL=${ENV_PUBLIC_URL}"
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}"
report_changed "wrote $(env_file) (0600, owner ${USERNAME}) — PORT, PUBLIC_URL, POSTGRES_URL. No secrets: every key lives in the secret store."
[[ -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
# =============================================================================
# 7. Secrets
# =============================================================================
#
# The store creates keys on demand, so this section is not strictly required —
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
# reasons: the file should exist with the right owner and mode before anything
# races to create it, and an install that finishes without ever saying the words
# "back this up" is one where nobody learns the file matters until it is gone.
report_section "Secrets"
step "Secrets"
if ! skip; then
echo ""
info "Secret store — $(secret_store_path)"
echo " Every encryption and signing key the platform holds, one SQLite file,"
echo " one key per purpose. Nothing goes in .env."
echo ""
echo " bootstrapped now:"
echo " jwt signs every session token"
echo " headscale encrypts the Headscale admin API key in Postgres"
echo ""
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
echo " service-connections — is created when its plugin is installed. A"
echo " plugin cannot read another plugin's key."
echo ""
if confirm "Create it?"; then
if bootstrap_secret_store; then
ok "created, 0600, owned by ${USERNAME}"
report_changed "created $(secret_store_path) (0600, dir 0700, owner ${USERNAME}) with keys for: jwt, headscale. Generated locally, never transmitted."
echo ""
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
echo " Losing it signs everyone out and makes every encrypted column in"
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
echo " the passphrase opens the inner envelope, this is the outer one."
echo ""
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
echo " and this is the key. Separate backups, or it is one theft."
SUMMARY+=("Secrets: $(secret_store_path)")
else
warn "could not create the store — the platform will create it on first use"
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
fi
else
warn "skipped by request — the platform will create it on first use"
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
fi
step_ok
fi
# =============================================================================
# 8. Schema
# =============================================================================
report_section "Schema"
step "Schema"
if ! skip; then
echo ""
# Counted from the aggregator rather than hardcoded, so the number is the truth
# even when a plugin line is uncommented. It was written as ${SCHEMA_TABLES:-?}
# and never assigned, so the section said "? tables" — a placeholder that looked
# like the count could not be determined rather than like nobody had set it.
SCHEMA_TABLES="$(schema_table_count)"
info "Database schema"
echo " ${SCHEMA_TABLES:-?} tables, applied with 'bun db:push' — drizzle-kit"
echo " diffs the schema code against Postgres and alters it directly. There"
echo " are no migration files and no migration table; the code is the source"
echo " of truth."
echo ""
echo " Only the CORE tables. Every plugin's tables are commented out in"
echo " src/databases/officer_db/src/schema.ts and get created when the"
echo " plugin is installed."
echo ""
if confirm "Push it?"; then
if OUT="$(push_schema)"; then
ok "schema applied"
report_changed "applied ${SCHEMA_TABLES} tables to Postgres with 'bun db:push' (drizzle-kit; no migration files)"
SUMMARY+=("Schema: ${SCHEMA_TABLES:-?} tables pushed")
else
warn "db:push failed"
echo "$OUT" | tail -12 | sed 's/^/ /'
SUMMARY+=("Schema: FAILED — see the output above")
fi
else
warn "skipped by request — the platform will not start without it"
SUMMARY+=("Schema: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 9. Build
# =============================================================================
report_section "Build"
step "Build"
if ! skip; then
echo ""
info "index.gen.html"
echo " 'bun gen:index' substitutes your public URL into index.html and"
echo " writes index.gen.html, which is the file the server imports. It is"
echo " gitignored, so a fresh clone never has one and the server has no page"
echo " to serve until this runs."
echo ""
echo " URL: ${ENV_PUBLIC_URL:-<not set — run the Environment section first>}"
echo ""
echo " To change it later: bun gen:index https://your.new.url"
echo ""
if [[ -z "$ENV_PUBLIC_URL" ]]; then
warn "PUBLIC_URL is not in $(env_file) — run the Environment section, then this one"
SUMMARY+=("Build: SKIPPED — no PUBLIC_URL")
elif confirm "Generate it?"; then
if OUT="$(gen_index)"; then
ok "$(gen_index_output)"
report_changed "generated $(gen_index_output) from index.html, substituting PUBLIC_URL=${ENV_PUBLIC_URL}"
SUMMARY+=("Build: index.gen.html for ${ENV_PUBLIC_URL}")
else
warn "gen:index failed"
echo "$OUT" | tail -8 | sed 's/^/ /'
SUMMARY+=("Build: FAILED — see the output above")
fi
else
warn "skipped by request — the server has no page to serve without it"
SUMMARY+=("Build: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 10. Services
# =============================================================================
report_section "Services"
step "Services"
if ! skip; then
echo ""
info "pm2 — $(ecosystem_file)"
echo " The ecosystem file is GENERATED, not checked in. It describes this"
echo " install and nothing else, so nothing in git can drift from it."
echo ""
echo " six processes:"
for entry in "${CORE_PROCESSES[@]}"; do
IFS='|' read -r _name _script _args <<<"$entry"
printf " %-24s %s %s\n" "$_name" "$_script" "$_args"
done
echo ""
echo " Nothing else. Every plugin adds its own entry when it is installed."
echo ""
if confirm "Write it and start them?"; then
write_ecosystem
ok "written — $(ecosystem_file)"
report_changed "wrote $(ecosystem_file) — six pm2 apps: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
# Starting against a database that is not answering is not fatal — the server
# waits and the agent retries forever — but it makes the Verify section below
# report a failure that is really just a race, and that is the kind of noise
# that teaches people to ignore a red line.
if pg_container_running && ! pg_wait_ready 30; then
warn "Postgres is not answering — starting anyway, but Verify may report failures"
fi
if OUT="$(pm2_start)"; then
ok "processes started"
report_started "pm2 startOrRestart: $(printf '%s ' "${CORE_PROCESSES[@]%%|*}")"
pm2_save >/dev/null 2>&1 && ok "process list saved (survives a pm2 restart)"
echo ""
if confirm "Start them on boot too?"; then
if pm2_enable_startup; then
ok "pm2 will resurrect them at boot"
report_ran "pm2 startup systemd — installed a systemd unit so pm2 resurrects these at boot"
SUMMARY+=("Services: 6 processes started, enabled at boot")
else
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
SUMMARY+=("Services: 6 processes started; boot hook NOT enabled")
fi
else
SUMMARY+=("Services: 6 processes started; not enabled at boot")
fi
else
warn "pm2 did not start cleanly"
echo "$OUT" | tail -12 | sed 's/^/ /'
SUMMARY+=("Services: FAILED to start — see the output above")
fi
else
warn "skipped by request"
SUMMARY+=("Services: SKIPPED by request")
fi
step_ok
fi
# =============================================================================
# 11. Verify
# =============================================================================
report_section "Verify"
step "Verify"
if ! skip; then
echo ""
info "Are the processes actually up?"
echo ""
VERIFY_BAD=0
while IFS='|' read -r vname vstatus vrestarts; do
[[ -z "$vname" ]] && continue
if [[ "$vstatus" == "online" ]]; then
if (( vrestarts > 3 )); then
warn "$(printf '%-24s online, but restarted %s times — check: pm2 logs %s' "$vname" "$vrestarts" "$vname")"
VERIFY_BAD=$((VERIFY_BAD + 1))
else
ok "$(printf '%-24s online' "$vname")"
fi
else
warn "$(printf '%-24s %s — check: pm2 logs %s' "$vname" "$vstatus" "$vname")"
VERIFY_BAD=$((VERIFY_BAD + 1))
fi
done < <(pm2_status_lines)
echo ""
# A process can be `online` and still be failing to serve — a restart loop takes
# a few seconds to show up in the counter, and the app can be up with a broken
# database. So the port is asked directly.
if curl -fsS --max-time 5 "http://127.0.0.1:${ENV_PORT:-9000}/api" >/dev/null 2>&1; then
ok "the API answers on 127.0.0.1:${ENV_PORT:-9000}"
SUMMARY+=("Verify: API answering on port ${ENV_PORT:-9000}")
else
warn "nothing answered on 127.0.0.1:${ENV_PORT:-9000}/api"
echo " pm2 logs officer is where the reason will be."
VERIFY_BAD=$((VERIFY_BAD + 1))
SUMMARY+=("Verify: the API did NOT answer on port ${ENV_PORT:-9000}")
fi
if (( VERIFY_BAD == 0 )); then
echo ""
ok "Officer is running. Open ${ENV_PUBLIC_URL:-http://localhost:${ENV_PORT:-9000}} and the"
echo " first-run screen will create the owner account."
fi
step_ok
fi
# =============================================================================
# 12. Proxy
# =============================================================================
#
# Optional, and last, because it is the only step that needs Officer to be already
# running: NPM proxies to it, and the gate below checks the bind address rather than
# taking a curl to loopback as proof.
#
# ── Why this section ignores --unattended ──
#
# Every other question in this script has a defensible default. None of these do — a
# domain name, a DNS provider and that provider's API credentials cannot be guessed —
# and the step is opt-in besides. So its prompts read stdin directly instead of going
# through confirm()/ask_required(), which honour ASSUME_YES.
#
# The valve is a TTY check, not the flag: with no terminal there is nobody to ask, so
# it skips and prints the manual instructions. That keeps a cron-driven install working
# without letting --unattended silently agree to publishing a public hostname.
report_section "Proxy"
step "Proxy"
if ! skip; then
echo ""
info "Reverse proxy — a real hostname and an HTTPS certificate"
echo " Optional. Skip it if you already run a proxy elsewhere, or if you"
echo " reach this instance over the tailnet and are happy with that."
echo ""
PROXY_PORT="${ENV_PORT:-9000}"
if [[ ! -t 0 ]]; then
warn "no terminal — skipping the proxy, which cannot be answered unattended"
proxy_skip_instructions "$PROXY_PORT"
SUMMARY+=("Proxy: skipped (no terminal)")
elif ! proxy_require_listening "$PROXY_PORT"; then
warn "skipping the proxy — Officer is not reachable the way NPM would reach it"
echo " Fix the bind address, then: officer-setup.sh --only Proxy"
SUMMARY+=("Proxy: skipped (Officer not listening on 0.0.0.0)")
elif ! proxy_confirm "Set up Nginx Proxy Manager now?"; then
proxy_skip_instructions "$PROXY_PORT"
SUMMARY+=("Proxy: skipped by request")
else
# One failure path for all of it: every function warns and returns non-zero rather
# than exiting, so a proxy that does not come up leaves a finished Officer install
# behind rather than a failed one. It is the last section for that reason.
PROXY_DOMAIN="$(proxy_ask 'Domain for this instance (e.g. officer.example.com)')"
if [[ -z "$PROXY_DOMAIN" ]]; then
warn "no domain given — skipping"
SUMMARY+=("Proxy: skipped (no domain)")
elif
proxy_detect_target &&
proxy_ensure_network &&
proxy_order_docker_after_tailscaled &&
proxy_write_compose &&
proxy_start &&
proxy_claim_admin &&
proxy_get_token &&
{ [[ "$CHALLENGE" != "dns" ]] || proxy_prompt_dns_credentials; } &&
proxy_wait_for_dns "$PROXY_DOMAIN" "$TARGET_IP" &&
proxy_allow_bridge_to_host "$PROXY_PORT" &&
proxy_create_host "$PROXY_DOMAIN" "$PROXY_PORT" &&
proxy_issue_certificate "$PROXY_DOMAIN" &&
proxy_attach_certificate
then
proxy_verify "$PROXY_DOMAIN"
echo ""
ok "Officer is published at https://${PROXY_DOMAIN}"
echo " NPM admin: http://127.0.0.1:81$([[ "$CHALLENGE" == "dns" ]] && echo " or http://${TARGET_IP}:81")"
SUMMARY+=("Proxy: https://${PROXY_DOMAIN}")
else
warn "the proxy did not finish — Officer itself is unaffected and still running"
echo " Retry just this part with: officer-setup.sh --only Proxy"
ERRORS+=("Proxy: did not finish")
SUMMARY+=("Proxy: FAILED — retry with --only Proxy")
fi
fi
step_ok
fi
# ── Who you are when this exits ──
#
# Root, and that surprises people — reasonably, because everything this script just
# installed belongs to somebody else. The platform runs as ${USERNAME}: the checkout,
# node_modules, .env, the secret store and all six pm2 processes are theirs. Root was
# the installer's privilege, never the platform's.
#
# Saying so matters for two things that are invisible until they bite:
#
# - group membership is fixed at login. ${USERNAME} was added to `docker` during
# machine setup, and a session that started before that does not have it — so
# `docker ps` fails for a reason that has nothing to do with docker.
# - the shell config was written into THEIR home. Staying as root means none of it
# is loaded, and the machine looks unconfigured.
if [[ "$EUID" -eq 0 ]]; then
echo ""
echo -e "${BOLD} One more thing — you are still root.${NC}"
echo ""
echo " Officer runs as ${USERNAME}, and everything it installed is theirs."
echo " Nothing here needs root any more. To carry on as them:"
echo ""
echo -e " ${BOLD}su - ${USERNAME}${NC} from this session"
echo -e " ${BOLD}ssh ${USERNAME}@<this machine>${NC} or log in fresh"
echo ""
echo " Either gives a new session, which is what makes their docker group"
echo " membership and their shell configuration take effect. Staying as root"
echo " means neither does, and the machine will look half-configured."
fi
echo ""
report_mark_complete