#!/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 ] [--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 ""