Both scripts had real macOS branches already — MACOS_SKIP, Xcode CLT, Docker Desktop
detection — but neither had ever run past its own library load on a real Mac, and
verifying that live is what this commit is. This machine is the Mac.
The root cause was the interpreter, not missing OS coverage: macOS's stock /bin/bash is
3.2.57 (Apple stopped shipping newer once bash went GPLv3) and this machine has no newer
one installed. Both scripts used bash 4+/4.3+ syntax with zero version guard, so a real
run died in seconds — declare -A (base.sh, associative array), local -n (packages.sh,
nameref), mapfile (5 sites), ${var,,} (both scripts' own step() — confirmed --only killed
the ENTIRE run), and machine-setup.sh's own pre-flight hard-refused brew outright ("this
script does not implement yet") even once the bash-4 crash was fixed. Each got rewritten
to its bash-3.2-safe equivalent rather than requiring a newer bash and re-executing under
it — nine call sites, a bounded fix, and one less thing that can go wrong before the
script can report what's wrong.
Past that gate, a live probe run (real account/path prompts, killed before Database/
Services could touch this machine's actual state) reproduced a second bug in the first
few seconds: resolve_user_home used getent (Linux/glibc, absent on macOS) with a
hardcoded /home/<user> fallback, so Layout tried `install -d /home/pastilhas` and failed
with "Operation not supported". Fixed with the portable `eval echo ~user` tilde-expansion
idiom, re-ran the same probe, and it correctly created /Users/pastilhas/officerdev/ and
started a real git clone.
Everything else follows the same shape — an unguarded Linux-only tool with a portable or
brew-based macOS equivalent:
- grep -oP (4 sites: both --list flags, env.sh's tailnet lookup, build.sh's schema
table count) — BSD grep has no -P at all. Portable ERE+sed where regex was enough;
jq where the input was already JSON (tailnet lookup) — jq is already an unconditional
dependency elsewhere in this script, so that's a correctness fix, not just a port.
- ss (postgres.sh, proxy.sh — is anything listening on this port, and is it bound wide
enough for NPM's container to reach) — both silently mis-reported on macOS, since the
"command not found" was swallowed by 2>/dev/null and read as "nothing's listening".
New shared `listening_addrs()` in officer-setup's base.sh, branching to lsof.
- getent hosts (proxy.sh's DNS-wait loop) — same silent-failure shape, would have spun
its full 30-minute timeout regardless of whether the record actually resolved. macOS
branch uses dscacheutil.
- stat -c (layout.sh's ownership check) — BSD stat has no -c, confirmed live ("illegal
option -- c"), and the failure was VISIBLE and made every layout directory report as
wrong-owner. Portable file_owner() tries -c then falls back to -f %Su.
- apt-only Postgres client, NodeSource's Debian-only setup script, lazydocker/lazygit's
installers (one literally named install_update_linux.sh, the other hardcoding
"_Linux_" into the release asset URL) — each gets a brew branch, formula names
verified against the live Homebrew catalog rather than assumed.
- sudo -u "$USERNAME" to become the SAME account the process already is — real on
Linux, where this whole script runs as root and dropping to the owner needs no
password; on macOS the process never escalates in the first place, so re-invoking
sudo for yourself asks for a real password on every call, or fails outright without
sudo rights. machine-setup's dev.sh had ~10 inline call sites (git, bun, deno, claude,
opencode, editor detection) rerouted through a new shared run_as_owner(); officer-
setup's own as_owner() (already the shared entry point there) got the same branch,
and six direct sudo call sites in build.sh/secrets.sh/services.sh that bypassed it
were rerouted through it too.
Three things stay Linux-only rather than getting an unverified macOS implementation,
each skipped with a stated reason at the point it would otherwise run: pm2's boot-
persistence hook (needs a launchd plist generated and loaded, not a systemd unit
printed and eval'd — real, separate work), the Tailscale exit-node NIC/sysctl tuning
(no macOS equivalent concept, and advertising a laptop as an exit node is already the
edge case MACOS_SKIP treats it as), and ufw's bridge-to-host firewall rule (pf is a
different rule language entirely, and Docker Desktop's own VM-based networking may not
even have the problem this rule exists to solve — guessing at a firewall rule felt like
the wrong place to be wrong).
Verified: both --list flags print step names instead of a grep usage error. A bounded
officer-setup.sh run (killed before Database) resolved the real macOS home directory,
created the layout with correct ownership, and began a real git clone — cleaned up
after (rm -rf the test directory, the gitignored .setup-progress file, the stray
install-report). machine-setup.sh's pre-flight now reports "Packages: brew" and reaches
the account/path prompts instead of refusing the OS outright. bash -n clean on all 16
files under bash 3.2, the actual interpreter these scripts run under on a Mac.
Not run further live: Database, Secrets, Services and Proxy would mutate this machine's
real state (a live Postgres, a live set of pm2 processes this session runs through) and
were left for a deliberate, supervised run rather than folded into validation.
983 lines
40 KiB
Bash
Executable File
983 lines
40 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)
|
|
# Portable ERE + sed rather than GNU grep's -P/\K — BSD grep on macOS has no -P at
|
|
# all and errors immediately ("invalid option -- P"), confirmed live.
|
|
grep -E '^step "' "${BASH_SOURCE[0]}" | sed -E 's/^step "([^"]+)".*/\1/'
|
|
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 is bash 4+; macOS's stock bash is 3.2, so this reads line-by-line instead.
|
|
MISSING=()
|
|
while IFS= read -r _line; do MISSING+=("$_line"); done < <(missing_tools)
|
|
MISSING_OPT=()
|
|
while IFS= read -r _line; do MISSING_OPT+=("$_line"); done < <(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 is bash 4+; macOS's stock bash is 3.2.
|
|
WRONG_OWNER=()
|
|
while IFS= read -r _line; do WRONG_OWNER+=("$_line"); done < <(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)")
|
|
|
|
# The affordances an agent needs to know about this platform. Not fatal: an
|
|
# install without them works, agents just go back to guessing what the client
|
|
# on the other end will render — which is what they did before this existed.
|
|
if capabilities_sync; then
|
|
ok "capabilities synced from ${CAPABILITIES_REPO}"
|
|
SUMMARY+=("Capabilities: synced")
|
|
else
|
|
warn "capabilities not synced — agents will have no affordances. Run 'git pull' in $(layout_items_dir), or press Update all on the marketplace screen."
|
|
SUMMARY+=("Capabilities: NOT synced")
|
|
fi
|
|
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
|
|
|
|
# The client goes on the HOST, before any of the container work, because it is the half
|
|
# that is not in the container. A member has their own Postgres role and no access to the
|
|
# owner's Docker socket, so `docker exec … psql` is the owner's tool, not theirs.
|
|
install_pg_client || ERRORS+=("psql: client not installed — members have no Postgres CLI")
|
|
|
|
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 ""
|
|
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. 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 " ${#CORE_PROCESSES[@]} 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) — ${#CORE_PROCESSES[@]} 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: ${#CORE_PROCESSES[@]} processes started, enabled at boot")
|
|
else
|
|
warn "could not enable the boot hook — run 'pm2 startup' yourself and follow it"
|
|
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} processes started; boot hook NOT enabled")
|
|
fi
|
|
else
|
|
SUMMARY+=("Services: ${#CORE_PROCESSES[@]} 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 every pm2 process 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
|