#!/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" # 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" 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:-}" \ bash "$SCRIPT_DIR/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_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}" [[ -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. 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}" 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 # ============================================================================= step "Schema" if ! skip; then echo "" 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" 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 # ============================================================================= 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:-}" echo "" echo " To change it later: bun gen:index https://your.new.url" echo "" if [[ -z "${ENV_PUBLIC_URL:-}" ]]; then ENV_PUBLIC_URL="$(env_get PUBLIC_URL)" fi 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)" 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 # ============================================================================= 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)" # 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" 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" 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 # ============================================================================= 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 echo "" echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet." echo ""