bun setup -- --repo https://github.com/you/platform.git The default is a private Gitea over SSH, which only authenticates on a machine whose key it already knows — so a genuinely fresh server could not clone at all without editing lib/repo.sh or knowing OFFICER_REPO existed. Added to both entry points. install.sh exports it rather than forwarding an argument it does not own; officer-setup.sh sets it before lib/repo.sh is sourced, which reads `${OFFICER_REPO:-<default>}`, so an absent flag still defaults. Two bugs found doing it, both pre-existing: - officer-setup.sh ALREADY had an arg parser, at the top, before the sources. My first attempt added a second one further down that was unreachable — every argument had already been consumed and `*)` would have exited 2 on --repo. Caught because `--help` printed the wrong usage. - both scripts re-execute through sudo passing `"$@"`, which the parse loop had already emptied with `shift`. So `officer-setup.sh --only build` run as a normal user silently became a FULL run the moment it escalated, and `install.sh --officer-only` re-ran the machine half. Nothing said so; the flag just stopped existing. ORIGINAL_ARGS is captured before the loop now. `${ORIGINAL_ARGS[@]+"${ORIGINAL_ARGS[@]}"}` is the set -u safe form — expanding an empty array is an error on bash before 4.4, and this runs on whatever the machine came with. Verified: bash -n on both, --help/--list/--repo/--repo=/unknown-option on both, the set -e behaviour of the guarded export, and that args survive the shift loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
846 lines
33 KiB
Bash
Executable File
846 lines
33 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
|
|
;;
|
|
-l | --list)
|
|
grep -oP '^step "\K[^"]+' "${BASH_SOURCE[0]}"
|
|
exit 0
|
|
;;
|
|
-h | --help)
|
|
echo "usage: officer-setup.sh [--only <step>] [--list] [--repo <url>]"
|
|
echo ""
|
|
echo " --only <step> run one step; --list names them"
|
|
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"
|
|
|
|
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:-}" \
|
|
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
|
|
|
|
|
|
echo ""
|
|
echo -e "${BOLD} Pre-flight complete.${NC} The remaining sections are not built yet."
|
|
echo ""
|
|
|
|
report_mark_complete
|