#!/bin/sh # BrandBrain agent installer — https://brandbrain.dev/install.sh # # curl -fsSL https://brandbrain.dev/install.sh | sh # curl -fsSL https://brandbrain.dev/install.sh | BRANDBRAIN_ENROLLMENT_KEY=bbe_… sh # curl -fsSL https://brandbrain.dev/install.sh | sh -s -- --enrollment-key bbe_… --labels zone=syd # # macOS: installs the BrandBrain menubar app (use --headless for a background # rig service under launchd). # Linux: installs the headless agent as a service (systemd, OpenRC, or a # background process where there is no init system, e.g. containers/WSL). # Windows: use PowerShell — irm https://brandbrain.dev/install.ps1 | iex # # Every download is verified against the release's SHA256SUMS. Run with --help # for all options. Re-running upgrades in place and keeps existing settings; # the macOS app asks before replacing an existing installation. # # The whole script is wrapped in functions and only runs at the very end, so a # truncated download can never execute half an install. set -eu BB_VERSION_REQ="${BRANDBRAIN_VERSION:-latest}" BB_DOWNLOAD_BASE="${BRANDBRAIN_DOWNLOAD_BASE:-https://brandbrain.dev/agent}" BB_BACKEND_URL="${BRANDBRAIN_BACKEND_URL:-}" BB_ENROLLMENT_KEY="${BRANDBRAIN_ENROLLMENT_KEY:-}" BB_TOKEN="${BRANDBRAIN_AGENT_TOKEN:-}" BB_AGENT_ID="${BRANDBRAIN_AGENT_ID:-}" BB_RIG_TYPE="${BRANDBRAIN_RIG_TYPE:-}" BB_CAPABILITIES="${BRANDBRAIN_RIG_CAPABILITIES:-}" BB_LABELS="${BRANDBRAIN_RIG_LABELS:-}" BB_CONCURRENCY="${BRANDBRAIN_AGENT_CONCURRENCY:-}" BB_MODE="" # app | headless (macOS); always headless on Linux BB_SCOPE="" # system | user (auto when empty) BB_BROWSER="auto" # auto | skip BB_START=1 BB_DRY_RUN=0 BB_YES=0 BB_UNINSTALL=0 BB_PURGE=0 SERVICE_NAME="brandbrain-agent" LAUNCHD_LABEL="dev.brandbrain.agent" TMP_DIR="" # --------------------------------------------------------------------------- # Output if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_BOLD=$(printf '\033[1m'); C_DIM=$(printf '\033[2m'); C_GRN=$(printf '\033[32m') C_YEL=$(printf '\033[33m'); C_RED=$(printf '\033[31m'); C_VIO=$(printf '\033[35m'); C_RST=$(printf '\033[0m') else C_BOLD=""; C_DIM=""; C_GRN=""; C_YEL=""; C_RED=""; C_VIO=""; C_RST="" fi say() { printf '%s\n' "$*"; } step() { printf '%s==>%s %s%s%s\n' "$C_VIO" "$C_RST" "$C_BOLD" "$*" "$C_RST"; } ok() { printf ' %s✓%s %s\n' "$C_GRN" "$C_RST" "$*"; } note() { printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RST"; } warn() { printf ' %s!%s %s\n' "$C_YEL" "$C_RST" "$*" >&2; } die() { printf '%serror:%s %s\n' "$C_RED" "$C_RST" "$*" >&2; exit 1; } has() { command -v "$1" >/dev/null 2>&1; } # run CMD… — execute, or print under --dry-run. run() { if [ "$BB_DRY_RUN" = 1 ]; then printf ' %s[dry-run]%s %s\n' "$C_DIM" "$C_RST" "$*" return 0 fi "$@" } # priv CMD… — run with root privileges (via sudo when needed). priv() { if [ "$(id -u)" -eq 0 ]; then run "$@" else run sudo "$@" fi } # quiet CMD… — run with output captured; show its tail only when it fails. quiet() { if [ "$BB_DRY_RUN" = 1 ]; then "$@"; return; fi "$@" >"$TMP_DIR/quiet.log" 2>&1 && return 0 rc=$? tail -n 15 "$TMP_DIR/quiet.log" | sed 's/^/ /' >&2 return "$rc" } # scoped CMD… — root for a system install, the current user for --user. scoped() { if [ "$BB_SCOPE" = system ]; then priv "$@"; else run "$@"; fi } usage() { cat </dev/null || echo 0)" = 1 ]; then ARCH=arm64 fi DISTRO="" if [ "$OS" = linux ] && [ -r /etc/os-release ]; then DISTRO=$(. /etc/os-release && printf '%s' "${ID:-}") fi WSL=0 if [ "$OS" = linux ] && grep -qi microsoft /proc/version 2>/dev/null; then WSL=1 fi if [ "$OS" = darwin ] && [ -z "$BB_MODE" ]; then # A key/token means this Mac is meant to be a fleet rig. if [ -n "$BB_ENROLLMENT_KEY" ] || [ -n "$BB_TOKEN" ]; then BB_MODE=headless; else BB_MODE=app; fi fi [ "$OS" = linux ] && BB_MODE=headless if [ -z "$BB_SCOPE" ]; then if [ "$OS" = darwin ]; then BB_SCOPE=user # LaunchAgent in the user's session; --system for a LaunchDaemon elif [ "$(id -u)" -eq 0 ] || has sudo; then BB_SCOPE=system else BB_SCOPE=user fi fi if [ "$BB_SCOPE" = system ] && [ "$(id -u)" -ne 0 ] && ! has sudo; then die "--system needs root or sudo (use --user for a per-user install)" fi } set_paths() { if [ "$BB_SCOPE" = system ]; then BIN_DIR=/usr/local/bin if [ "$OS" = darwin ]; then STATE_DIR="/Library/Application Support/BrandBrain/agent" ENV_FILE="$STATE_DIR/.env" LOG_DIR="/Library/Logs/BrandBrain" else STATE_DIR=/var/lib/brandbrain ENV_FILE=/etc/brandbrain/rig.env LOG_DIR=/var/log fi else BIN_DIR="$HOME/.local/bin" if [ "$OS" = darwin ]; then # Separate from the menubar app's ~/.brandbrain so both can coexist. STATE_DIR="$HOME/Library/Application Support/BrandBrain/agent" LOG_DIR="$HOME/Library/Logs/BrandBrain" else STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/brandbrain-agent" LOG_DIR="$STATE_DIR" fi ENV_FILE="$STATE_DIR/.env" fi BIN="$BIN_DIR/brandbrain-agent" } # --------------------------------------------------------------------------- # Downloads fetch() { # fetch URL DEST if has curl; then curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" elif has wget; then wget -q -O "$2" "$1" else die "curl or wget is required" fi } fetch_stdout() { # fetch_stdout URL if has curl; then curl -fsSL --retry 3 --retry-delay 2 "$1" elif has wget; then wget -q -O - "$1" else die "curl or wget is required" fi } sha256_of() { if has sha256sum; then sha256sum "$1" | awk '{print $1}' elif has shasum; then shasum -a 256 "$1" | awk '{print $1}' elif has openssl; then openssl dgst -sha256 "$1" | awk '{print $NF}' else die "sha256sum, shasum or openssl is required to verify downloads" fi } # json_field KEY < JSON — first string value of "KEY" (flat lookup; the # manifest is generated by scripts/release-agent-binaries.sh). json_field() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n 1 } # SemVer 2.0 without build metadata: releases are MAJOR.MINOR.PATCH[-prerelease]. is_semver() { printf '%s\n' "$1" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' } resolve_release() { base=${BB_DOWNLOAD_BASE%/} BB_VERSION_REQ=${BB_VERSION_REQ#v} if [ "$BB_VERSION_REQ" != latest ] && ! is_semver "$BB_VERSION_REQ"; then die "--version must be a release like 1.8.0 or 1.8.1-dev.4.gabc123 (got $BB_VERSION_REQ)" fi # App releases have their own signed feed and may precede a headless release. if [ "$OS" = darwin ] && [ "$BB_MODE" = app ] && [ "$BB_VERSION_REQ" = latest ]; then app_feed=$(fetch_stdout "https://brandbrain.dev/appcast-v2.xml") || die "could not reach the macOS update feed" APP_VERSION=$(printf '%s\n' "$app_feed" | sed -n 's/.*\([^<]*\)<\/sparkle:shortVersionString>.*/\1/p' | head -n 1) is_semver "$APP_VERSION" || die "macOS update feed has no valid release" VERSION=$APP_VERSION APP_DMG_URL="https://brandbrain.dev/releases/BrandBrainAgent-${APP_VERSION}.dmg" return 0 fi if [ "$BB_VERSION_REQ" = latest ]; then MANIFEST=$(fetch_stdout "$base/latest.json") || die "could not reach $base/latest.json" else MANIFEST=$(fetch_stdout "$base/$BB_VERSION_REQ/manifest.json") || die "version $BB_VERSION_REQ not found at $base" fi VERSION=$(printf '%s' "$MANIFEST" | json_field version) is_semver "$VERSION" || die "release manifest at $base is malformed (version '$VERSION')" APP_DMG_URL=$(printf '%s' "$MANIFEST" | json_field dmg_url) APP_VERSION=$(printf '%s\n' "$MANIFEST" | awk '/"macos_app"/{f=1} f && /"version"/{gsub(/.*"version"[[:space:]]*:[[:space:]]*"|".*/,""); print; exit}') RELEASE_URL="$base/$VERSION" } # download_verified FILE — fetch FILE from the release and check SHA256SUMS. download_verified() { file=$1 fetch "$RELEASE_URL/SHA256SUMS" "$TMP_DIR/SHA256SUMS" || die "could not download $RELEASE_URL/SHA256SUMS" want=$(awk -v f="$file" '$2 == f || $2 == "*" f {print $1}' "$TMP_DIR/SHA256SUMS") [ -n "$want" ] || die "$file is not listed in the release checksums" note "downloading $file" fetch "$RELEASE_URL/$file" "$TMP_DIR/$file" || die "download failed: $RELEASE_URL/$file" got=$(sha256_of "$TMP_DIR/$file") [ "$got" = "$want" ] || die "checksum mismatch for $file (expected $want, got $got)" ok "verified sha256 $(printf '%s' "$got" | cut -c1-16)…" } # --------------------------------------------------------------------------- # Env file (KEY="value" lines; values validated in parse_args) env_get() { # env_get FILE KEY [ -r "$1" ] || return 0 sed -n "s/^$2=\"\\{0,1\\}\\([^\"]*\\)\"\\{0,1\\}\$/\\1/p" "$1" | tail -n 1 } # write_env_file — merge flags into the existing env file (flags win, other # keys and comments are kept) and install it with owner-only permissions. write_env_file() { existing="$TMP_DIR/env.existing" : >"$existing" if [ -f "$ENV_FILE" ]; then if [ "$BB_SCOPE" = system ] && [ "$(id -u)" -ne 0 ]; then # shellcheck disable=SC2024 # reading a root-only file into our own temp file sudo cat "$ENV_FILE" >"$existing" 2>/dev/null || true else cat "$ENV_FILE" >"$existing" fi fi merged="$TMP_DIR/env.merged" cp "$existing" "$merged" if ! grep -q '^# BrandBrain agent' "$merged" 2>/dev/null; then { printf '# BrandBrain agent settings (managed by install.sh; edits are kept on upgrade)\n'; cat "$merged"; } >"$merged.tmp" mv "$merged.tmp" "$merged" fi set_kv() { # set_kv KEY VALUE (empty VALUE = leave as is) [ -n "$2" ] || return 0 awk -v k="$1" -v line="$1=\"$2\"" ' index($0, k "=") == 1 { if (!done) print line; done = 1; next } { print } END { if (!done) print line }' "$merged" >"$merged.tmp" mv "$merged.tmp" "$merged" } set_kv BRANDBRAIN_BACKEND_URL "$BB_BACKEND_URL" set_kv BRANDBRAIN_ENROLLMENT_KEY "$BB_ENROLLMENT_KEY" set_kv BRANDBRAIN_AGENT_TOKEN "$BB_TOKEN" set_kv BRANDBRAIN_AGENT_ID "$BB_AGENT_ID" set_kv BRANDBRAIN_RIG_TYPE "$BB_RIG_TYPE" set_kv BRANDBRAIN_RIG_CAPABILITIES "$BB_CAPABILITIES" set_kv BRANDBRAIN_RIG_LABELS "$BB_LABELS" set_kv BRANDBRAIN_AGENT_CONCURRENCY "$BB_CONCURRENCY" set_kv BRANDBRAIN_CONFIG_DIR "$STATE_DIR" if [ -n "${BROWSER_PATH:-}" ]; then set_kv DISCOVERY_STEALTH_CRAWLER_ENABLED true set_kv CHROMIUM_PATH "$BROWSER_PATH" fi if [ "$BB_DRY_RUN" = 1 ]; then note "[dry-run] write $ENV_FILE (0600):" sed -e 's/^\(BRANDBRAIN_ENROLLMENT_KEY="........\).*"/\1…(redacted)"/' \ -e 's/^\(BRANDBRAIN_AGENT_TOKEN="........\).*"/\1…(redacted)"/' "$merged" | sed 's/^/ /' return 0 fi if cmp -s "$existing" "$merged" 2>/dev/null && [ -s "$existing" ]; then ok "settings unchanged ($ENV_FILE)" return 0 fi if [ "$BB_SCOPE" = system ]; then priv mkdir -p "$(dirname "$ENV_FILE")" priv install -m 0600 "$merged" "$ENV_FILE" if [ "$OS" = darwin ] && [ -n "${RUN_AS:-}" ]; then priv chown "$RUN_AS" "$ENV_FILE" fi else mkdir -p "$(dirname "$ENV_FILE")" (umask 077 && cp "$merged" "$ENV_FILE") chmod 0600 "$ENV_FILE" fi ok "settings written to $ENV_FILE" } # --------------------------------------------------------------------------- # Browser (Linux rigs: the stealth crawler's Chromium tier) find_browser() { for b in chromium chromium-browser google-chrome google-chrome-stable; do if has "$b"; then command -v "$b"; return 0; fi done for b in /usr/bin/chromium /usr/bin/chromium-browser /usr/lib/chromium/chromium /opt/google/chrome/chrome; do if [ -x "$b" ]; then printf '%s' "$b"; return 0; fi done return 1 } ensure_browser() { BROWSER_PATH="" [ "$OS" = linux ] || return 0 if BROWSER_PATH=$(find_browser); then ok "browser: $BROWSER_PATH" return 0 fi if [ "$BB_BROWSER" = skip ]; then note "skipping browser install (--no-browser): the rig will run HTTP-only crawls" return 0 fi if [ "$BB_SCOPE" != system ]; then warn "no Chromium found and --user can't install packages; install chromium yourself for the browser tier" return 0 fi step "Installing Chromium for the browser crawl tier" note "this can take a minute or two" if has apt-get; then quiet priv env DEBIAN_FRONTEND=noninteractive apt-get update -qq || true if [ "$DISTRO" = ubuntu ]; then # Ubuntu's chromium package is a snap shim that doesn't work in # services/containers; use Google Chrome on amd64. if [ "$ARCH" = amd64 ]; then if fetch https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb "$TMP_DIR/chrome.deb"; then quiet priv env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$TMP_DIR/chrome.deb" fonts-liberation fonts-noto-color-emoji \ || warn "Chrome install failed" else warn "could not download Google Chrome" fi else warn "Ubuntu arm64 has no non-snap Chromium; the rig will run HTTP-only crawls" fi else quiet priv env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji \ || warn "chromium install failed" fi elif has apk; then quiet priv apk add --no-cache -q chromium nss freetype harfbuzz ttf-freefont font-noto-emoji || warn "chromium install failed" elif has dnf; then quiet priv dnf install -y -q chromium || warn "chromium install failed (on RHEL-family systems enable EPEL first)" elif has yum; then quiet priv yum install -y -q chromium || warn "chromium install failed (enable EPEL first)" elif has pacman; then quiet priv pacman -Sy --noconfirm --needed chromium noto-fonts || warn "chromium install failed" elif has zypper; then quiet priv zypper -n install chromium || warn "chromium install failed" else warn "unknown package manager: install chromium yourself for the browser tier" fi if [ "$BB_DRY_RUN" = 1 ]; then BROWSER_PATH=/usr/bin/chromium; return 0; fi if BROWSER_PATH=$(find_browser); then ok "browser: $BROWSER_PATH"; else BROWSER_PATH=""; fi } # --------------------------------------------------------------------------- # Binary capture_helpers_required() { [ "$OS" = darwin ] || return 1 release_core=${VERSION%%-*} release_major=${release_core%%.*} release_minor=${release_core#*.}; release_minor=${release_minor%%.*} [ "$release_major" -gt 1 ] || { [ "$release_major" -eq 1 ] && [ "$release_minor" -ge 14 ]; } } appium_runtime_required() { [ "$OS" = darwin ] || return 1 release_core=${VERSION%%-*} release_major=${release_core%%.*} release_minor=${release_core#*.}; release_minor=${release_minor%%.*} [ "$release_major" -gt 1 ] || { [ "$release_major" -eq 1 ] && [ "$release_minor" -ge 15 ]; } } appium_runtime_ready() { [ -d "$BIN_DIR/appium-runtime" ] && [ ! -L "$BIN_DIR/appium-runtime" ] || return 1 for asset in spec.json package.json package-lock.json; do [ -f "$BIN_DIR/appium-runtime/$asset" ] && [ -s "$BIN_DIR/appium-runtime/$asset" ] && [ ! -L "$BIN_DIR/appium-runtime/$asset" ] || return 1 done } # Older release manifests omit this field and keep their existing behavior. clef_runtime_required() { [ "$(printf '%s' "${MANIFEST:-}" | json_field clef_runtime)" = bundled-v1 ] } clef_runtime_ready() { [ -d "$BIN_DIR/clef" ] && [ ! -L "$BIN_DIR/clef" ] || return 1 for asset in run.sh server.py; do [ -f "$BIN_DIR/clef/$asset" ] && [ -s "$BIN_DIR/clef/$asset" ] && [ ! -L "$BIN_DIR/clef/$asset" ] || return 1 done [ -x "$BIN_DIR/clef/run.sh" ] } stage_clef_runtime() { mkdir -p "$TMP_DIR/extract/clef" for asset in run.sh server.py; do member="clef/$asset" listing=$(tar -tvzf "$TMP_DIR/$file" "$member") || die "$member is missing from the release archive" [ "$(printf '%s\n' "$listing" | wc -l | tr -d ' ')" = 1 ] || die "$member must occur exactly once in the release archive" case "$listing" in -*) ;; *) die "$member must be a regular file in the release archive" ;; esac tar -xOzf "$TMP_DIR/$file" "$member" >"$TMP_DIR/extract/$member" || die "could not extract $member" [ -s "$TMP_DIR/extract/$member" ] || die "$member is empty in the release archive" done } check_clef_runtime_destination() { clef_target="$BIN_DIR/clef" [ ! -L "$clef_target" ] || die "cannot replace linked Clef runtime assets at $clef_target" if [ -e "$clef_target" ]; then [ -d "$clef_target" ] || die "cannot replace non-directory Clef runtime assets at $clef_target" for existing_asset in "$clef_target"/* "$clef_target"/.[!.]* "$clef_target"/..?*; do [ -e "$existing_asset" ] || [ -L "$existing_asset" ] || continue case "${existing_asset##*/}" in run.sh|server.py) [ -f "$existing_asset" ] && [ ! -L "$existing_asset" ] || die "unsafe Clef runtime asset at $existing_asset" ;; *) die "unrecognized content in Clef runtime assets at $clef_target" ;; esac done fi } install_staged_clef_runtime() { clef_target="$BIN_DIR/clef" clef_stage=$(scoped mktemp -d "$BIN_DIR/.brandbrain-clef.XXXXXX") scoped install -m 0755 "$TMP_DIR/extract/clef/run.sh" "$clef_stage/run.sh" scoped install -m 0644 "$TMP_DIR/extract/clef/server.py" "$clef_stage/server.py" scoped chmod 0755 "$clef_stage" clef_backup="" if [ -d "$clef_target" ]; then clef_backup=$(scoped mktemp -d "$BIN_DIR/.brandbrain-clef-old.XXXXXX") scoped rmdir "$clef_backup" scoped mv "$clef_target" "$clef_backup" fi if scoped mv "$clef_stage" "$clef_target"; then if [ -n "$clef_backup" ]; then for asset in run.sh server.py; do scoped rm -f "$clef_backup/$asset"; done scoped rmdir "$clef_backup" fi else if [ -n "$clef_backup" ]; then scoped mv "$clef_backup" "$clef_target"; fi die "could not replace Clef runtime assets" fi } # Only the three reviewed regular-file members can become runtime assets. # No tar directory extraction or links are allowed to choose destinations. stage_appium_runtime() { mkdir -p "$TMP_DIR/extract/appium-runtime" for asset in spec.json package.json package-lock.json; do member="appium-runtime/$asset" listing=$(tar -tvzf "$TMP_DIR/$file" "$member") || die "$member is missing from the release archive" [ "$(printf '%s\n' "$listing" | wc -l | tr -d ' ')" = 1 ] || die "$member must occur exactly once in the release archive" case "$listing" in -*) ;; *) die "$member must be a regular file in the release archive" ;; esac tar -xOzf "$TMP_DIR/$file" "$member" >"$TMP_DIR/extract/$member" || die "could not extract $member" [ -s "$TMP_DIR/extract/$member" ] || die "$member is empty in the release archive" chmod 0644 "$TMP_DIR/extract/$member" done } check_appium_runtime_destination() { runtime_target="$BIN_DIR/appium-runtime" [ ! -L "$runtime_target" ] || die "cannot replace linked Appium runtime assets at $runtime_target" if [ -e "$runtime_target" ]; then [ -d "$runtime_target" ] || die "cannot replace non-directory Appium runtime assets at $runtime_target" # Refuse unrelated content rather than remove an operator's directory. for existing_asset in "$runtime_target"/* "$runtime_target"/.[!.]* "$runtime_target"/..?*; do [ -e "$existing_asset" ] || [ -L "$existing_asset" ] || continue case "${existing_asset##*/}" in spec.json|package.json|package-lock.json) [ -f "$existing_asset" ] && [ ! -L "$existing_asset" ] || die "unsafe Appium runtime asset at $existing_asset" ;; *) die "unrecognized content in Appium runtime assets at $runtime_target" ;; esac done fi } install_staged_appium_runtime() { runtime_target="$BIN_DIR/appium-runtime" runtime_stage=$(scoped mktemp -d "$BIN_DIR/.brandbrain-appium-runtime.XXXXXX") for asset in spec.json package.json package-lock.json; do scoped install -m 0644 "$TMP_DIR/extract/appium-runtime/$asset" "$runtime_stage/$asset" done scoped chmod 0755 "$runtime_stage" # Directory replacement uses a reserved backup path. Restore on a failed # rename; a running server uses its separate, immutable state generation. runtime_backup="" if [ -d "$runtime_target" ]; then runtime_backup=$(scoped mktemp -d "$BIN_DIR/.brandbrain-appium-runtime-old.XXXXXX") scoped rmdir "$runtime_backup" scoped mv "$runtime_target" "$runtime_backup" fi if scoped mv "$runtime_stage" "$runtime_target"; then if [ -n "$runtime_backup" ]; then for asset in spec.json package.json package-lock.json; do scoped rm -f "$runtime_backup/$asset"; done scoped rmdir "$runtime_backup" fi else if [ -n "$runtime_backup" ]; then scoped mv "$runtime_backup" "$runtime_target"; fi die "could not replace Appium runtime assets" fi } # Only exact, single regular-file members are accepted. Extracting their bytes # to files we create prevents archive links or paths from choosing destinations. stage_release_binary() { binary_name=$1 listing=$(tar -tvzf "$TMP_DIR/$file" "$binary_name") || die "$binary_name is missing from the release archive" [ "$(printf '%s\n' "$listing" | wc -l | tr -d ' ')" = 1 ] || die "$binary_name must occur exactly once in the release archive" case "$listing" in -*) ;; *) die "$binary_name must be a regular file in the release archive" ;; esac tar -xOzf "$TMP_DIR/$file" "$binary_name" >"$TMP_DIR/extract/$binary_name" || die "could not extract $binary_name" [ -s "$TMP_DIR/extract/$binary_name" ] || die "$binary_name is empty in the release archive" chmod 0755 "$TMP_DIR/extract/$binary_name" } install_staged_binary() { binary_name=$1 binary_target="$BIN_DIR/$binary_name" # Reserve an unpredictable staging file so stale .new links cannot redirect # writes. Rename leaves a running executable intact until its replacement. binary_stage=$(scoped mktemp "$BIN_DIR/.brandbrain-$binary_name.XXXXXX") scoped install -m 0755 "$TMP_DIR/extract/$binary_name" "$binary_stage" scoped mv -f "$binary_stage" "$binary_target" if [ "$OS" = darwin ]; then xattr -d com.apple.quarantine "$binary_target" 2>/dev/null || true; fi } install_binary() { file="brandbrain-agent-$VERSION-$OS-$ARCH.tar.gz" binaries="brandbrain-agent" helpers_ready=1 if capture_helpers_required; then binaries="brandbrain-app-capture brandbrain-app-capture-control brandbrain-agent" for helper in brandbrain-app-capture brandbrain-app-capture-control; do if [ ! -f "$BIN_DIR/$helper" ] || [ -L "$BIN_DIR/$helper" ] || [ ! -x "$BIN_DIR/$helper" ]; then helpers_ready=0; fi done fi if appium_runtime_required; then check_appium_runtime_destination if ! appium_runtime_ready; then helpers_ready=0; fi fi if clef_runtime_required; then check_clef_runtime_destination if ! clef_runtime_ready; then helpers_ready=0; fi fi # mv follows a destination directory, including a symlink to one. Check # every target before installing any file so a bad companion cannot leave # a partial upgrade or receive a replacement inside another directory. for binary_name in $binaries; do if [ -d "$BIN_DIR/$binary_name" ]; then die "cannot replace directory at $BIN_DIR/$binary_name; move that directory or directory link aside and run the installer again" fi done # Exact match: "1.8.0" must not match an installed "1.8.0-dev.3.g…". if [ "$helpers_ready" = 1 ] && [ -x "$BIN" ] && [ ! -L "$BIN" ] && [ "$("$BIN" --version 2>/dev/null | awk '{print $2; exit}')" = "$VERSION" ]; then ok "brandbrain-agent $VERSION already installed at $BIN" return 0 fi download_verified "$file" if [ "$BB_DRY_RUN" = 1 ]; then for binary_name in $binaries; do note "[dry-run] install $BIN_DIR/$binary_name"; done if appium_runtime_required; then note "[dry-run] install $BIN_DIR/appium-runtime"; fi if clef_runtime_required; then note "[dry-run] install $BIN_DIR/clef"; fi return 0 fi mkdir -p "$TMP_DIR/extract" for binary_name in $binaries; do stage_release_binary "$binary_name"; done if appium_runtime_required; then stage_appium_runtime; fi if clef_runtime_required; then stage_clef_runtime; fi scoped mkdir -p "$BIN_DIR" if appium_runtime_required; then install_staged_appium_runtime; fi if clef_runtime_required; then install_staged_clef_runtime; fi for binary_name in $binaries; do install_staged_binary "$binary_name"; done ok "installed $BIN ($VERSION)" } # --------------------------------------------------------------------------- # Linux services linux_service_kind() { if [ "$BB_SCOPE" = system ]; then if has systemctl && [ -d /run/systemd/system ]; then echo systemd elif has openrc-run && has rc-service; then echo openrc else echo background fi else if has systemctl && systemctl --user show-environment >/dev/null 2>&1; then echo systemd-user else echo background fi fi } ensure_system_user() { if id brandbrain >/dev/null 2>&1; then return 0; fi if has useradd; then priv useradd --system --home-dir "$STATE_DIR" --shell /usr/sbin/nologin --user-group brandbrain 2>/dev/null \ || priv useradd --system --home-dir "$STATE_DIR" --shell /sbin/nologin brandbrain elif has adduser; then priv adduser -S -D -H -h "$STATE_DIR" -s /sbin/nologin brandbrain 2>/dev/null \ || priv adduser --system --group --home "$STATE_DIR" --no-create-home brandbrain id -g brandbrain >/dev/null 2>&1 || true else die "cannot create the brandbrain system user (no useradd/adduser)" fi ok "created system user brandbrain" } install_linux_system_state() { ensure_system_user priv mkdir -p "$STATE_DIR" group=$(id -gn brandbrain 2>/dev/null || echo brandbrain) priv chown "brandbrain:$group" "$STATE_DIR" priv chmod 0750 "$STATE_DIR" } write_file() { # write_file DEST MODE (content on stdin; system scope uses sudo) cat >"$TMP_DIR/write.tmp" if [ "$BB_DRY_RUN" = 1 ]; then note "[dry-run] write $1"; return 0; fi if [ "$BB_SCOPE" = system ]; then priv mkdir -p "$(dirname "$1")" priv install -m "$2" "$TMP_DIR/write.tmp" "$1" else mkdir -p "$(dirname "$1")" install -m "$2" "$TMP_DIR/write.tmp" "$1" fi } setup_systemd() { write_file "/etc/systemd/system/$SERVICE_NAME.service" 0644 </dev/null || echo brandbrain) EnvironmentFile=$ENV_FILE Environment=BRANDBRAIN_CONFIG_DIR=$STATE_DIR Environment=HOME=$STATE_DIR ExecStart=$BIN --standalone Restart=always RestartSec=10 # SIGTERM drains: in-flight jobs finish before exit. KillSignal=SIGTERM TimeoutStopSec=600 NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=$STATE_DIR PrivateTmp=true [Install] WantedBy=multi-user.target EOF priv systemctl daemon-reload if [ "$BB_START" = 1 ]; then priv systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || priv systemctl enable "$SERVICE_NAME" priv systemctl restart "$SERVICE_NAME" ok "systemd service $SERVICE_NAME enabled and started" else ok "systemd service installed (not started: --no-start)" fi LOG_HINT="journalctl -u $SERVICE_NAME -f" } setup_systemd_user() { unit="$HOME/.config/systemd/user/$SERVICE_NAME.service" write_file "$unit" 0644 </dev/null 2>&1 || true run systemctl --user restart "$SERVICE_NAME" ok "user service $SERVICE_NAME started" fi if has loginctl && [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)" != yes ]; then note "to keep the agent running after you log out: sudo loginctl enable-linger $(id -un)" fi LOG_HINT="journalctl --user -u $SERVICE_NAME -f" } setup_openrc() { group=$(id -gn brandbrain 2>/dev/null || echo brandbrain) write_file "/etc/init.d/$SERVICE_NAME" 0755 </dev/null if [ "$BB_START" = 1 ]; then priv rc-service "$SERVICE_NAME" restart >/dev/null 2>&1 || priv rc-service "$SERVICE_NAME" start ok "OpenRC service $SERVICE_NAME started" fi LOG_HINT="tail -f /var/log/brandbrain-agent.log" } # No init system (containers, WSL without systemd): a plain background # process. It won't survive a reboot — say so. setup_background() { pidfile="$STATE_DIR/agent.background.pid" log="$LOG_DIR/brandbrain-agent.log" stop_background if [ "$BB_START" != 1 ]; then return 0; fi if [ "$BB_DRY_RUN" = 1 ]; then note "[dry-run] start $BIN --standalone in the background"; return 0; fi launcher="$TMP_DIR/launch.sh" # The launcher reads the env file first (as root for a system install: the # file is root-only because it holds the enrollment key), then drops to the # service user with the settings already in its environment. # Drop to the service user: runuser (util-linux), su-exec (Alpine), su # (busybox/shadow; keeps the environment without -l), or util-linux setpriv. exec_line="exec \"$BIN\" --standalone" if [ "$BB_SCOPE" = system ]; then if has runuser; then exec_line="exec runuser -u brandbrain -- \"$BIN\" --standalone" elif has su-exec; then exec_line="exec su-exec brandbrain \"$BIN\" --standalone" elif has su; then exec_line="exec su -s /bin/sh brandbrain -c 'exec \"$BIN\" --standalone'" elif setpriv --help 2>&1 | grep -q reuid; then exec_line="exec setpriv --reuid=brandbrain --regid=brandbrain --init-groups \"$BIN\" --standalone" else warn "no runuser/su-exec/su/setpriv on this system: the agent will run as root" fi fi { printf 'set -a\n. "%s"\nset +a\n' "$ENV_FILE" printf 'export BRANDBRAIN_CONFIG_DIR="%s" HOME="%s"\n' "$STATE_DIR" "$STATE_DIR" printf '%s\n' "$exec_line" } >"$launcher" if [ "$BB_SCOPE" = system ]; then priv touch "$log" group=$(id -gn brandbrain 2>/dev/null || echo brandbrain) priv chown "brandbrain:$group" "$log" priv install -m 0700 "$launcher" "$STATE_DIR/launch.sh" priv sh -c "nohup sh '$STATE_DIR/launch.sh' >>'$log' 2>&1 & echo \$! >'$pidfile'" else mkdir -p "$LOG_DIR" install -m 0700 "$launcher" "$STATE_DIR/launch.sh" nohup sh "$STATE_DIR/launch.sh" >>"$log" 2>&1 & echo $! >"$pidfile" fi ok "agent started in the background (no init system found)" warn "it will not restart after a reboot; re-run this installer or use a systemd/OpenRC host" LOG_HINT="tail -f $log" } # pid_running PID — alive and not a zombie. An exited agent whose parent never # reaps it (e.g. a container whose PID 1 is a plain shell) stays a zombie, # which kill -0 still reports as present. pid_running() { kill -0 "$1" 2>/dev/null || return 1 if [ -r "/proc/$1/status" ]; then ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null else case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1 ;; esac fi } stop_background() { pidfile="$STATE_DIR/agent.background.pid" [ -f "$pidfile" ] || return 0 pid=$(cat "$pidfile" 2>/dev/null || true) if [ -n "$pid" ] && pid_running "$pid"; then note "stopping the running agent (pid $pid, draining in-flight jobs)" scoped kill -TERM "$pid" 2>/dev/null || true i=0 while pid_running "$pid" && [ $i -lt 600 ]; do sleep 1; i=$((i + 1)); done fi scoped rm -f "$pidfile" } # --------------------------------------------------------------------------- # macOS launchd_plist() { # launchd_plist [UserName] cat < Label$LAUNCHD_LABEL ProgramArguments $BIN--standalone EnvironmentVariables BRANDBRAIN_CONFIG_DIR$STATE_DIR RunAtLoad KeepAlive ThrottleInterval10 ExitTimeOut600 ProcessTypeBackground StandardOutPath$LOG_DIR/brandbrain-agent.log StandardErrorPath$LOG_DIR/brandbrain-agent.log EOF if [ -n "${1:-}" ]; then printf ' UserName%s\n' "$1"; fi printf '\n\n' } setup_launchd() { if [ "$BB_SCOPE" = system ]; then RUN_AS=${SUDO_USER:-$(id -un)} plist="/Library/LaunchDaemons/$LAUNCHD_LABEL.plist" domain=system priv mkdir -p "$STATE_DIR" "$LOG_DIR" priv chown "$RUN_AS" "$STATE_DIR" "$LOG_DIR" launchd_plist "$RUN_AS" | write_file "$plist" 0644 else plist="$HOME/Library/LaunchAgents/$LAUNCHD_LABEL.plist" domain="gui/$(id -u)" run mkdir -p "$STATE_DIR" "$LOG_DIR" launchd_plist | write_file "$plist" 0644 fi if [ "$BB_START" = 1 ]; then if [ "$BB_SCOPE" = system ]; then priv launchctl bootout "$domain/$LAUNCHD_LABEL" 2>/dev/null || true priv launchctl bootstrap "$domain" "$plist" else run launchctl bootout "$domain/$LAUNCHD_LABEL" 2>/dev/null || true run launchctl bootstrap "$domain" "$plist" fi ok "launchd service $LAUNCHD_LABEL started" else ok "launchd service $LAUNCHD_LABEL installed (not started: --no-start)" fi LOG_HINT="tail -f \"$LOG_DIR/brandbrain-agent.log\"" } install_macos_app() { [ -n "$APP_DMG_URL" ] || die "this release has no macOS app; use --headless for the background agent" step "Installing BrandBrain for macOS ${APP_VERSION:+(v$APP_VERSION)}" major=$(sw_vers -productVersion 2>/dev/null | cut -d. -f1) if [ -n "$major" ] && [ "$major" -lt 14 ] 2>/dev/null; then die "the BrandBrain app needs macOS 14 or later (this Mac runs $(sw_vers -productVersion)); use --headless instead" fi dest=/Applications if [ ! -w "$dest" ]; then dest="$HOME/Applications"; fi app_sudo=0 installed_app="" for candidate in /Applications/BrandBrainAgent.app "$HOME/Applications/BrandBrainAgent.app"; do if [ -d "$candidate" ]; then installed_app=$candidate dest=$(dirname "$candidate") break fi done if [ -n "$installed_app" ]; then note "BrandBrain is already installed at $installed_app" if [ "$BB_DRY_RUN" = 1 ]; then note "[dry-run] would ask to reinstall the app at $installed_app" elif [ ! -w "$dest" ]; then [ "$dest" = /Applications ] && has sudo \ || die "cannot replace $installed_app: $dest is not writable" app_sudo=1 note "administrator access will be requested to replace the app in /Applications" fi if [ "$BB_DRY_RUN" = 0 ] && [ "$BB_YES" != 1 ]; then if ! ( : <>/dev/tty ) 2>/dev/null; then die "reinstall needs a terminal for confirmation (or pass --yes)" fi printf 'Reinstall BrandBrain %s at %s? [y/N] ' "$APP_VERSION" "$installed_app" >/dev/tty \ || die "could not show reinstall confirmation" IFS= read -r answer /dev/null 2>&1; then hdiutil detach -quiet "$mnt" die "the downloaded app failed Gatekeeper verification; not installing" fi osascript -e "quit app \"${name%.app}\"" >/dev/null 2>&1 || true if [ "$app_sudo" = 1 ]; then priv rm -rf "${dest:?}/${name:?}.new" priv ditto "$app" "$dest/$name.new" priv rm -rf "${dest:?}/${name:?}" priv mv "$dest/$name.new" "$dest/$name" else mkdir -p "$dest" rm -rf "${dest:?}/${name:?}.new" ditto "$app" "$dest/$name.new" rm -rf "${dest:?}/${name:?}" mv "$dest/$name.new" "$dest/$name" fi hdiutil detach -quiet "$mnt" || true ok "installed $dest/$name" if [ "$BB_START" = 1 ]; then open "$dest/$name" && ok "opened $name — look for the brain icon in your menu bar" fi say "" say "${C_BOLD}Next:${C_RST} sign in from the menu bar app. Updates install automatically." say "Running a Mac as a fleet rig instead? ${C_DIM}curl -fsSL https://brandbrain.dev/install.sh | sh -s -- --headless --enrollment-key bbe_…${C_RST}" } # --------------------------------------------------------------------------- # Verification diag_port() { p="" if [ -r "$STATE_DIR/diag-port" ]; then p=$(cat "$STATE_DIR/diag-port" 2>/dev/null || true) elif [ "$BB_SCOPE" = system ] && [ "$(id -u)" -ne 0 ]; then p=$(sudo cat "$STATE_DIR/diag-port" 2>/dev/null || true) fi printf '%s' "${p:-19179}" } http_get_local() { # http_get_local URL — quick local probe with curl or wget if has curl; then curl -fsS -m 2 "$1" 2>/dev/null elif has wget; then wget -q -T 2 -O - "$1" 2>/dev/null else return 1 fi } wait_for_agent() { [ "$BB_START" = 1 ] && [ "$BB_DRY_RUN" = 0 ] || return 0 has curl || has wget || return 0 step "Waiting for the agent" i=0 while [ $i -lt 30 ]; do if http_get_local "http://127.0.0.1:$(diag_port)/health" >/dev/null; then break; fi sleep 1; i=$((i + 1)) done if [ $i -ge 30 ]; then warn "the agent hasn't answered on 127.0.0.1:$(diag_port) yet — check the logs: ${LOG_HINT:-}" return 0 fi ok "agent is running" if [ -z "$BB_ENROLLMENT_KEY" ] && [ -z "$BB_TOKEN" ] && [ -z "$(existing_credential)" ]; then return 0 fi i=0 status="" while [ $i -lt 45 ]; do status=$(http_get_local "http://127.0.0.1:$(diag_port)/status" || true) if printf '%s' "$status" | grep -Eq '"backend_aware":[[:space:]]*true'; then break; fi sleep 1; i=$((i + 1)) done rig_id=$(printf '%s\n' "$status" | sed -n 's/.*"agent_id":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) admin=$(printf '%s\n' "$status" | sed -n 's/.*"admin_state":[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) if printf '%s' "$status" | grep -Eq '"backend_aware":[[:space:]]*true'; then ok "joined the fleet as ${C_BOLD}${rig_id:-this machine}${C_RST} (scheduling: ${admin:-active})" else warn "the agent hasn't registered with the fleet yet — check the logs: ${LOG_HINT:-}" fi } existing_credential() { if [ -r "$STATE_DIR/rig-token" ]; then echo yes; return 0; fi [ -n "$(env_get "$ENV_FILE" BRANDBRAIN_ENROLLMENT_KEY 2>/dev/null)" ] && echo yes [ -n "$(env_get "$ENV_FILE" BRANDBRAIN_AGENT_TOKEN 2>/dev/null)" ] && echo yes return 0 } # --------------------------------------------------------------------------- # Uninstall uninstall() { step "Uninstalling the BrandBrain agent ($BB_SCOPE)" if [ "$OS" = darwin ]; then if [ "$BB_SCOPE" = system ]; then priv launchctl bootout "system/$LAUNCHD_LABEL" 2>/dev/null || true priv rm -f "/Library/LaunchDaemons/$LAUNCHD_LABEL.plist" else run launchctl bootout "gui/$(id -u)/$LAUNCHD_LABEL" 2>/dev/null || true run rm -f "$HOME/Library/LaunchAgents/$LAUNCHD_LABEL.plist" fi if [ "$BB_MODE" = app ]; then for d in /Applications "$HOME/Applications"; do for a in "$d/BrandBrainAgent.app" "$d/BrandBrain.app" "$d/BrandBrain Agent.app"; do if [ -d "$a" ]; then osascript -e 'quit app "BrandBrainAgent"' >/dev/null 2>&1 || true; run rm -rf "$a"; ok "removed $a"; fi done done fi else case "$(linux_service_kind)" in systemd) priv systemctl disable --now "$SERVICE_NAME" 2>/dev/null || true priv rm -f "/etc/systemd/system/$SERVICE_NAME.service" priv systemctl daemon-reload 2>/dev/null || true ;; systemd-user) run systemctl --user disable --now "$SERVICE_NAME" 2>/dev/null || true run rm -f "$HOME/.config/systemd/user/$SERVICE_NAME.service" ;; openrc) priv rc-service "$SERVICE_NAME" stop 2>/dev/null || true priv rc-update del "$SERVICE_NAME" default 2>/dev/null || true priv rm -f "/etc/init.d/$SERVICE_NAME" ;; esac stop_background fi if [ -e "$BIN" ]; then if [ "$BB_SCOPE" = system ]; then priv rm -f "$BIN"; else run rm -f "$BIN"; fi ok "removed $BIN" fi if [ "$OS" = darwin ]; then for helper in brandbrain-app-capture brandbrain-app-capture-control; do if [ -e "$BIN_DIR/$helper" ] || [ -L "$BIN_DIR/$helper" ]; then scoped rm -f "$BIN_DIR/$helper" ok "removed $BIN_DIR/$helper" fi done # Dependency specifications are install assets, while downloaded runtime # generations remain in the user's state directory unless purged. if [ -d "$BIN_DIR/appium-runtime" ] && [ ! -L "$BIN_DIR/appium-runtime" ]; then for asset in spec.json package.json package-lock.json; do if [ -f "$BIN_DIR/appium-runtime/$asset" ] || [ -L "$BIN_DIR/appium-runtime/$asset" ]; then scoped rm -f "$BIN_DIR/appium-runtime/$asset" fi done scoped rmdir "$BIN_DIR/appium-runtime" 2>/dev/null || warn "kept nonempty Appium runtime asset directory" fi fi if [ -d "$BIN_DIR/clef" ] && [ ! -L "$BIN_DIR/clef" ]; then for asset in run.sh server.py; do if [ -f "$BIN_DIR/clef/$asset" ] || [ -L "$BIN_DIR/clef/$asset" ]; then scoped rm -f "$BIN_DIR/clef/$asset" fi done scoped rmdir "$BIN_DIR/clef" 2>/dev/null || warn "kept nonempty Clef runtime asset directory" fi if [ "$BB_PURGE" = 1 ]; then if [ "$BB_SCOPE" = system ]; then priv rm -rf "$STATE_DIR" [ "$OS" = linux ] && priv rm -rf /etc/brandbrain else run rm -rf "$STATE_DIR" fi ok "removed state and settings (this machine will enroll as a new rig next time)" else note "kept $STATE_DIR (rig identity and settings); add --purge to delete it" fi } # --------------------------------------------------------------------------- # Main cleanup() { if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then rm -rf "$TMP_DIR"; fi; } main() { parse_args "$@" detect_platform set_paths TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t brandbrain) trap cleanup EXIT INT TERM say "" wsl_tag=""; [ "$WSL" = 1 ] && wsl_tag=", WSL" say "${C_BOLD}BrandBrain agent installer${C_RST} ${C_DIM}($OS/$ARCH${DISTRO:+, $DISTRO}$wsl_tag; $BB_MODE; $BB_SCOPE)${C_RST}" [ "$BB_DRY_RUN" = 1 ] && note "dry run: nothing will be changed" if [ "$BB_UNINSTALL" = 1 ]; then uninstall exit 0 fi step "Resolving release" resolve_release ok "brandbrain-agent $VERSION" if [ "$OS" = darwin ] && [ "$BB_MODE" = app ]; then install_macos_app exit 0 fi if [ "$BB_SCOPE" = system ] && [ "$(id -u)" -ne 0 ] && [ "$BB_DRY_RUN" = 0 ]; then note "installing system-wide; you may be asked for your password (sudo)" fi step "Installing the agent" if [ "$OS" = linux ] && [ "$BB_SCOPE" = system ]; then install_linux_system_state fi install_binary ensure_browser step "Configuring" if [ -z "$BB_ENROLLMENT_KEY" ] && [ -z "$BB_TOKEN" ] && [ -z "$(existing_credential)" ]; then warn "no enrollment key or token: the agent will install but can't take jobs until it has one" note "get a key from CRM → Fleet → Enrollment, then re-run with BRANDBRAIN_ENROLLMENT_KEY=bbe_…" fi if [ "$OS" = darwin ] && [ "$BB_SCOPE" = system ]; then RUN_AS=${SUDO_USER:-$(id -un)}; fi write_env_file step "Starting the service" LOG_HINT="" if [ "$OS" = darwin ]; then setup_launchd else kind=$(linux_service_kind) case "$kind" in systemd) setup_systemd ;; systemd-user) setup_systemd_user ;; openrc) setup_openrc ;; background) setup_background ;; esac fi wait_for_agent say "" say "${C_GRN}${C_BOLD}BrandBrain agent $VERSION is installed.${C_RST}" say " binary $BIN" say " settings $ENV_FILE" say " state $STATE_DIR" [ -n "${LOG_HINT:-}" ] && say " logs $LOG_HINT" say " upgrade re-run this installer · remove: … | sh -s -- --uninstall" say "" } main "$@"