#!/usr/bin/env bash
#
# ngss-dev-setup.sh - set up, and re-check, a VA Mobile / NGSS developer Mac.
#
# Automates these ngss-onboarding-site pages, then lists what still needs a person:
#   New Hire Checklist (workstation setup and "Build a Java Service"), Development Environment
#   Setup, Token and Environment Configuration, Maven Configuration, Skaffold and Kustomize
#   Setup, K8s Monitoring Tools, Jira CLI, Jenkins CLI, Modular Git Hooks, and the Token
#   Verification Checklist.
#
# Written for the bash 3.2 that ships with macOS, because it runs before Homebrew exists.
# Usage: ngss-dev-setup.sh [install|doctor|credentials|clone|repos|intellij|tidy|forget|update|upgrade] [options]   (see --help)
# Docs and the latest version: https://onboarding.apothesource.io/apothesource-onboarding/new-hire/setup-script.html
# Tests: scripts/test-dev-setup/run.sh in the ngss-onboarding-site repository.
# shellcheck disable=SC2088 # a quoted "~" in messages is meant literally

# Started with sh (bash in POSIX mode) or another shell? Run it with bash instead.
if [ -z "${BASH_VERSION:-}" ] || case ":${SHELLOPTS:-}:" in *:posix:*) true ;; *) false ;; esac; then
  if [ -f "$0" ]; then exec /bin/bash "$0" "$@"; fi
  echo "Run this script with bash: bash ngss-dev-setup.sh" >&2
  exit 1
fi

set +e -uo pipefail
# Settings in the user's environment that change what cd and grep print
unset CDPATH GREP_OPTIONS GREP_COLOR GREP_COLORS
# macOS's own command folders, in case a profile left one off PATH: without /usr/sbin, sysctl isn't found and an
# Apple Silicon Mac looks like an Intel one. Added at the end, so nothing earlier on PATH changes.
for d in /usr/bin /bin /usr/sbin /sbin; do case ":${PATH:-}:" in *":$d:"*) ;; *) PATH="${PATH:+$PATH:}$d" ;; esac; done

# Always macOS's own tools, and in the C locale: GNU versions earlier on PATH take different
# options (sed -i, stat -f, id -F), and some locales write and read 1.5 as "1,5".
awk() { LC_ALL=C /usr/bin/awk "$@"; }
id() { /usr/bin/id "$@"; }
sed() { LC_ALL=C /usr/bin/sed "$@"; }
stat() { /usr/bin/stat "$@"; }
tr() { LC_ALL=C /usr/bin/tr "$@"; }
curl() { command curl -q "$@"; } # -q (only works first) skips ~/.curlrc, where --include or -o would change what we read

VERSION="1.6.0"
ME="ngss-dev-setup.sh"
DOCS_URL="https://onboarding.apothesource.io/apothesource-onboarding/new-hire/setup-script.html"
SCRIPT_URL="https://onboarding.apothesource.io/apothesource-onboarding/_attachments/dev-setup/ngss-dev-setup.sh"

# --- What gets installed (keep in sync with the onboarding site) ----------------------------
JAVA_DEFAULT="21.0.10-tem"   # dev-env-setup.adoc, and what the service .sdkmanrc files pin
JAVA_EXTRA="17.0.13-tem"     # service-lint
MAVEN_VERSION="3.9.15"       # service .sdkmanrc pins; a bare `sdk install maven` now gives 3.10.x
BREW_FORMULAS="skaffold kustomize jq wget trufflehog" # trufflehog: the secret scans in the git hooks
RECOMMENDED="intellij postman headlamp plantuml jira jenkins githooks macos"
EXTRAS="iterm2 sublime avd citrix"
# upgrade: apps that update themselves (their casks set auto_updates), so Homebrew never upgrades them,
# and tools that builds, deployments or the git hooks run, so a new major version waits for the team
SELF_UPDATING_CASKS="orbstack docker-desktop intellij-idea postman iterm2 sublime-text citrix-workspace"
HOLD_MAJOR="skaffold kustomize trufflehog"

# name|Coderepo project/repo|why it is cloned
REPOS="location-service|vdms/location-service|the New Hire Checklist build
starter-project|ckm/starter-project|the Developer Starter Project
jwtgen|iums/jwtgen|test JWTs for the Starter Project
mobile-framework|dhsss/mobile-framework|framework source and the IntelliJ CodeStyle/Inspections files
ngss-maven-tiles|ckm/ngss-maven-tiles|the Maven build tiles"

# --- Endpoints ------------------------------------------------------------------------------
CODEREPO_HOST="coderepo.mobilehealth.va.gov"
CODEREPO="https://$CODEREPO_HOST"
DTR_HOST="dtr.mapsandbox.net"
JIRA_SERVER="https://issues.mobilehealth.va.gov"
JENKINS_URL="https://jenkins.mapsandbox.net"
BITBUCKET_CHECK_URL="$CODEREPO/scm/ckm/common-app-config.git/info/refs?service=git-upload-pack"
NEXUS_CHECK_URL="https://nexus.mobilehealth.va.gov/content/repositories/ckm-releases/"
DTR_CHECK_URL="https://$DTR_HOST/v2/"
DTR_TEST_IMAGE="$DTR_HOST/ckm/callback-service:1.9.0"
JIRA_PROJECT="CKM"
JIRA_BOARDS="Greenfield |Greenfield|None"   # the board's real name ends with a space
ONBOARDING_USERS_REGEX="mccormickc|felch"   # accounts behind the onboarding tokens (token-checklist.adoc)
LOCAL_CONTEXTS='^(orbstack|docker-desktop|rancher-desktop|colima.*|minikube|kind-.*|k3d-.*)$'
BACKSTAGE_API="https://backstage.apothesource.io/backstage/api/catalog/entities"
SITE_ATTACHMENTS="https://onboarding.apothesource.io/apothesource-onboarding/_attachments"
# Time limits, in seconds, so a stuck tool can't hang the script (overridable for testing)
DOCKER_TIMEOUT="${NGSS_DOCKER_TIMEOUT:-20}" # docker info hangs while an engine is stuck starting
PROBE_TIMEOUT="${NGSS_PROBE_TIMEOUT:-30}"   # a new login shell, in case a startup file waits on something
NET_TIMEOUT="${NGSS_NET_TIMEOUT:-30}"       # jira me and docker manifest inspect, which can stall off VPN
NEED_KB=31457280                            # 30 GB free for install: tools, JDKs, IntelliJ, container images
LOW_KB=10485760                             # under 10 GB, install will most likely run out of space
DOCTOR_LOW_KB=20971520                      # doctor warns under 20 GB
NOTIFY_AFTER="${NGSS_NOTIFY_AFTER:-180}"    # install posts a macOS notification when it took longer than this

# --- IntelliJ IDEA (dev-env-setup, intellij-settings, bitbucketplugin and copilot-setup pages) ---
# Plugin IDs: PlantUML Integration, Google Cloud Code, Bitbucket Integration Pro, GitHub Copilot
IDEA_PLUGINS="${NGSS_IDEA_PLUGINS:-PlantUML integration|com.google.gct.core|com.majera.intellij.codereview.bitbucket|com.github.copilot}"
IDEA_CODE_STYLE="VA_Mobile_CodeStyle"     # scheme name in mobile-framework's VA_Mobile_CodeStyle.xml
IDEA_INSPECTIONS="VA_Mobile_Inspections"  # profile name in mobile-framework's VA_Mobile_Inspections.xml
IDE_FILES_PATH="docs/appendices/ide"      # where mobile-framework keeps both files

# --- Files ----------------------------------------------------------------------------------
NGSS_DIR="$HOME/.config/ngss"
CRED_FILE="$NGSS_DIR/credentials.env"
SETTINGS_FILE="$NGSS_DIR/settings" # what install remembers for the other commands: workspace=<folder>
LOG_DIR="$NGSS_DIR/logs"
SDKMAN_DIR="${SDKMAN_DIR:-$HOME/.sdkman}"
JENKINS_JAR="$HOME/.jenkins-cli/jenkins-cli.jar"
JIRA_CONFIG="$HOME/.config/.jira/.config.yml"
HOOKS_DIR="$HOME/.git-hooks"
# Where apps and Homebrew live. The tests point these into their sandbox, so what's installed on the Mac running them doesn't matter.
APP_DIRS="${NGSS_APP_DIRS-$HOME/Applications:/Applications}" # colon-separated, ~/Applications (Toolbox) first
BREW_ARM="${NGSS_BREW_ARM:-/opt/homebrew}"                   # Homebrew's prefix on Apple Silicon
BREW_X86="${NGSS_BREW_X86:-/usr/local}"                      # and on Intel (or under Rosetta)
# shellcheck disable=SC2016 # literal text to look for
OLD_DISPATCH_LINE='input="$(cat || true)" ;;' # dispatcher line from git-hooks.adoc before Oct 2026; it broke pre-push
OLD_PUSH_SCAN_SHA="1fce922c7f4ba700b0a37497c0534ed5e822ea3de2f972f7dc5a9142bf5e4a6b" # pre-push scan before 1.2.1; it could scan nothing
# Earlier hook files this script wrote (path|SHA-256), replaced when unchanged; ones you edited are kept.
# 1.2.1: renamed files slipped past the pre-commit checks, the staged scan read the working tree, the scans took any
# trufflehog error for a finding, and the dispatcher skipped each repository's own hooks and failed pushes with more
# input than a pipe holds. 1.3.0: the staged scan left out unverified findings, such as private keys. 1.5.0 (the last
# two lines): the pre-push scan ignored the refs being pushed, scanning every local branch, and skipped a new repository's
# first push; the staged scan put every staged path on trufflehog's command line, which a large commit overflows.
OLD_HOOKS="dispatch|35344eff4bd9e8f202a906163f59e0f0f6cea09d41299e56eacfe49cb274c926
pre-commit.d/10-merge-conflict-guard|5f534e028ea841c92d9325f66976a1c8c976707718bc419b8aa99302876ef2cf
pre-commit.d/20-build-artifact-guard|ee36f2fb17230fbfc801e45810b13ef0102f9612e689485f97680144b1e557e5
pre-commit.d/30-trufflehog-staged-scan|e0ad7de80ae4884f006f37d1320e37d1333161651f32c8560176f9e259d8967f
pre-push.d/20-trufflehog-full-scan|726a94f85cc2d8518a12dee8dfb1c9a5aefe301c665f661ce54bfc07cb39fbd3
pre-commit.d/30-trufflehog-staged-scan|6b0ba3371f992d8a007f1c93f623e7b484f2e3c4930da59782bb325170bc1a22
pre-commit.d/30-trufflehog-staged-scan|2fcf5147772866334017eb2a9c249cbdf0a70b48e02e26de6e05701e1393f298
pre-push.d/20-trufflehog-full-scan|22df9d3a50f5428666cbce2f09d28c88e283f5604d66388cfc75b7f0156227f6"
# Other hook events linked to the dispatcher, so the global core.hooksPath doesn't hide each repository's own hooks for
# them, such as Git LFS's post-checkout or a Jira-key commit-msg hook. Not reference-transaction: it runs for every ref update.
PASS_HOOKS="applypatch-msg pre-applypatch post-applypatch prepare-commit-msg commit-msg post-commit pre-merge-commit pre-rebase post-checkout post-merge post-rewrite"
MARK_BEGIN="# >>> ngss-dev-setup >>>"
MARK_END="# <<< ngss-dev-setup <<<"
CRED_VARS="VA_BITBT_USER VA_BITBT_PWD VA_NEXUS_USER VA_NEXUS_PWD DTR_USER DTR_PWD JIRA_API_TOKEN JENKINS_API_USER JENKINS_API_TOKEN"
PROBE_VARS="SKAFFOLD_PUSH SKAFFOLD_RPC_PORT DTR_URL PATH $CRED_VARS"

# --- Options and state ----------------------------------------------------------------------
COMMAND="install"
ASSUME_YES=0
DRY_RUN="${DRY_RUN:-0}"
RUNTIME=""
WORKSPACE_DIR="$HOME/Projects"
MINIMAL=0
DO_CLONE=1
DO_BUILD=1
CRED_IMPORT_FILE=""
CLONE_SYSTEMS=""
DO_FIX=0
DO_PULL=0
DO_FETCH=1
DO_CLEAN=0
DO_FORCE=0
SELECTED=""
GIT_NAME=""
GIT_EMAIL=""
JIRA_LOGIN="${JIRA_LOGIN:-}"
SET_JAVA_DEFAULT=1
RUN_TS="$(date '+%Y%m%d-%H%M%S')"
TTY="/dev/tty"
NL=$'\n'
TAB=$'\t'
NOTE=""
N_OK=0
N_WARN=0
N_BAD=0
R_NAME=()
R_STATE=()
R_NOTE=()
FIX_N=0 # fixes doctor --fix can offer: label, default answer, and the command to run
FIX_LABEL=()
FIX_DEF=()
FIX_CMD=()
ARM_MAC=0 # Apple Silicon, even when this shell runs under Rosetta; set in main
ZDIR="" # where zsh reads .zshrc and .zprofile: $ZDOTDIR when ~/.zshenv (or the environment) sets it, otherwise HOME; set in main
BASH_PROFILE_NEW=0 # 1 when ~/.bash_profile didn't exist as this run started; see chain_bash_profile
ORIG_ARGS=() # the command line, and the path of this file (empty when piped into bash), for native_restart
SELF=""
BG_PID="" # a background job the INT trap must stop (it starts with SIGINT ignored, so Ctrl-C alone won't)
FRESH_OK=0
FRESH_TIMEOUT=0
SDK_PICKED=""
CHECK_CODE=""
CHECK_HOST=""
TODO_N=0
PROFILES_DIR=""
PROFILES_TMP=""
PROFILES_FROM=""

# --- Output ---------------------------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  B=$'\033[1m' DIM=$'\033[2m' RED=$'\033[31m' GRN=$'\033[32m' YLW=$'\033[33m' BLU=$'\033[34m' RST=$'\033[0m'
else
  B="" DIM="" RED="" GRN="" YLW="" BLU="" RST=""
fi
section() { printf '\n%s==> %s%s\n' "$B$BLU" "$*" "$RST"; }
say() { printf '    %s\n' "$*"; }
note() { printf '          %s%s%s\n' "$DIM" "$*" "$RST"; }
ok() { N_OK=$((N_OK + 1)); printf '    %s%-4s%s  %s\n' "$GRN" ok "$RST" "$*"; }
warn() { N_WARN=$((N_WARN + 1)); printf '    %s%-4s%s  %s\n' "$YLW" warn "$RST" "$*"; }
bad() { N_BAD=$((N_BAD + 1)); printf '    %s%-4s%s  %s\n' "$RED" FAIL "$RST" "$*"; }
skip() { printf '    %s%-4s%s  %s\n' "$DIM" skip "$RST" "$*"; }
info() { printf '    %s%-4s%s  %s\n' "$BLU" info "$RST" "$*"; }
dry() { printf '    %s[dry-run]%s %s\n' "$YLW" "$RST" "$*"; }
die() { printf '%sError:%s %s\n' "$RED" "$RST" "$*" >&2; exit 1; }

# --- Prompts (read from the terminal, so `curl ... | bash` works too) -----------------------
have_tty() { (: <"$TTY") 2>/dev/null; }
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
ask() { # ask PROMPT [DEFAULT] -> answer on stdout
  local prompt="$1" def="${2:-}" ans=""
  if [ "$ASSUME_YES" = 1 ]; then printf '%s' "$def"; return 0; fi
  [ -z "$def" ] || prompt="$prompt [$def]"
  printf '%s: ' "$prompt" >"$TTY"
  IFS= read -r ans <"$TTY" || ans=""
  ans="$(trim "$ans")"
  printf '%s' "${ans:-$def}"
}
ask_yn() { # ask_yn PROMPT Y|N -> status 0 for yes
  local def="${2:-N}" ans="" hint="[y/N]"
  [ "$def" = Y ] && hint="[Y/n]"
  if [ "$ASSUME_YES" = 1 ]; then [ "$def" = Y ]; return; fi
  while :; do
    printf '%s %s ' "$1" "$hint" >"$TTY"
    IFS= read -r ans <"$TTY" || ans=""
    case "${ans:-$def}" in [Yy] | [Yy][Ee][Ss]) return 0 ;; [Nn] | [Nn][Oo]) return 1 ;; esac
  done
}
ask_secret() { # ask_secret PROMPT -> value on stdout, hidden while typing
  local ans=""
  printf '%s: ' "$1" >"$TTY"
  IFS= read -r -s ans <"$TTY" || ans=""
  printf '\n' >"$TTY"
  trim "$ans"
}

# --- Small utilities ------------------------------------------------------------------------
has() { command -v "$1" >/dev/null 2>&1; }
is_encrypted() { case "$1" in "{"*"}") return 0 ;; esac; return 1; } # Maven-encrypted: only Maven can use it
tildify() { case "$1" in "$HOME" | "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;; *) printf '%s' "$1" ;; esac; }
untildify() { case "$1" in "~" | "~/"*) printf '%s%s' "$HOME" "${1#"~"}" ;; *) printf '%s' "$1" ;; esac; }
abs_dir() { # PATH BASE -> PATH with ~ expanded, made absolute against BASE, without a trailing slash
  local p
  p="$(untildify "$1")"
  case "$p" in /*) ;; *) p="${2%/}/$p" ;; esac
  while [ "${#p}" -gt 1 ] && [ "${p%/}" != "$p" ]; do p="${p%/}"; done
  printf '%s' "$p"
}
selected() { case " $SELECTED " in *" $1 "*) return 0 ;; esac; return 1; }
run() { if [ "$DRY_RUN" = 1 ]; then dry "$*"; return 0; fi; "$@"; }
elapsed() { local s=$(($(date +%s) - $1)); printf '%dm%02ds' $((s / 60)) $((s % 60)); }
clt_ready() { xcode-select -p >/dev/null 2>&1; }
git_ready() { clt_ready && git --version >/dev/null 2>&1; }
repo_names() { printf '%s\n' "$REPOS" | awk -F'|' '{ printf "%s%s", (NR > 1 ? ", " : ""), $1 }'; }
proc_tree() { # PID -> it and every process started under it, so nothing a stuck command started is left running
  local all level i=0
  if all="$(/bin/ps -Ao pid=,ppid= 2>/dev/null)" && [ -n "$all" ]; then
    printf '%s\n' "$all" | awk -v root="$1" '
      { kids[$2] = kids[$2] " " $1 }
      END { q = root; while (q != "") { n = split(q, p, " "); q = ""; for (i = 1; i <= n; i++) { printf "%s ", p[i]; q = q kids[p[i]] } } }'
    return 0
  fi
  level="$1" # no ps (a sandbox can block it, as it's setuid): go down one generation at a time with pgrep
  # shellcheck disable=SC2086 # lists of PIDs
  while [ -n "$level" ] && [ "$i" -lt 20 ]; do
    printf '%s ' $level
    level="$(/usr/bin/pgrep -P "$(echo $level | tr ' ' ',')" 2>/dev/null | tr '\n' ' ')"
    i=$((i + 1))
  done
}
stop_tree() { # PID: stop it and everything it started. TERM first, so tools such as git can remove their lock files; KILL a second later
  local pids="" more p i=0
  kill -STOP "$1" 2>/dev/null
  while [ "$i" -lt 5 ]; do # freeze each level before looking further down, so nothing starts a process we'd miss
    more=""
    for p in $(proc_tree "$1"); do case " $pids " in *" $p "*) ;; *) more="$more $p" ;; esac; done
    [ -n "$more" ] || break
    # shellcheck disable=SC2086 # a list of PIDs
    kill -STOP $more 2>/dev/null
    pids="$pids$more"
    i=$((i + 1))
  done
  # shellcheck disable=SC2086
  kill -TERM $pids 2>/dev/null
  # shellcheck disable=SC2086
  kill -CONT $pids 2>/dev/null
  sleep 1
  # shellcheck disable=SC2086
  kill -KILL $pids 2>/dev/null
}
run_limited() { # SECONDS CMD...: run CMD, stopping it and whatever it started after SECONDS (then 124) so a stuck tool can't hang the script
  local ticks=$(($1 * 10)) n=0 pid rc prev="$BG_PID"
  shift
  "$@" &
  pid=$!
  BG_PID=$pid
  # Inside $(...), the main INT trap never sees BG_PID, and a background job ignores Ctrl-C, so stop it from here
  [ "${BASH_SUBSHELL:-0}" = 0 ] || trap 'stop_tree "$pid"; exit 130' INT TERM
  while kill -0 "$pid" 2>/dev/null; do
    if [ "$n" -ge "$ticks" ]; then
      stop_tree "$pid"
      wait "$pid" 2>/dev/null
      BG_PID="$prev"
      [ "${BASH_SUBSHELL:-0}" = 0 ] || trap - INT TERM
      return 124
    fi
    sleep 0.1
    n=$((n + 1))
  done
  wait "$pid"
  rc=$?
  BG_PID="$prev"
  [ "${BASH_SUBSHELL:-0}" = 0 ] || trap - INT TERM
  return $rc
}
json_value() { # FILE KEY -> a top-level string in a JSON file
  local v
  if v="$(plutil -extract "$2" raw -o - "$1" 2>/dev/null)" && [ -n "$v" ]; then printf '%s\n' "$v"; return 0; fi
  sed -n "s/.*\"$2\" *: *\"\\([^\"]*\\)\".*/\\1/p" "$1" 2>/dev/null | head -n 1 # plutil before macOS 12 has no raw
}

setup_path() { # make tools from Homebrew, OrbStack and Docker Desktop findable in this process
  local d old_ifs="$IFS"
  # NGSS_TOOL_DIRS (colon-separated) replaces this list; the tests set it to keep out the tools of the Mac running them
  IFS=:
  for d in ${NGSS_TOOL_DIRS-$BREW_ARM/bin:$BREW_X86/bin:$HOME/.orbstack/bin:$HOME/.docker/bin:/Applications/Docker.app/Contents/Resources/bin:$HOME/Applications/Docker.app/Contents/Resources/bin}; do
    [ -d "$d" ] || continue
    case ":$PATH:" in *":$d:"*) ;; *) PATH="$PATH:$d" ;; esac
  done
  IFS="$old_ifs"
  # On Apple Silicon, Homebrew's native copy wins over an Intel one in /usr/local, whatever the PATH says
  if arm_mac && [ -d "$BREW_ARM/bin" ]; then case "$PATH" in "$BREW_ARM/bin:"*) ;; *) PATH="$BREW_ARM/bin:$BREW_ARM/sbin:$PATH" ;; esac; fi
  export PATH
}

backup() { # FILE -> FILE.ngss-backup-<time>, private because profiles can hold secrets
  local b="$1.ngss-backup-$RUN_TS"
  [ -f "$1" ] && [ ! -e "$b" ] || return 0
  cp -p "$1" "$b" && chmod 600 "$b" && note "backup: $(tildify "$b")"
}

wait_until() { # SECONDS LABEL CMD... : poll every 5 seconds
  local limit="$1" label="$2" t=0
  shift 2
  "$@" && return 0
  printf '    waiting for %s ' "$label"
  while [ "$t" -lt "$limit" ]; do
    sleep 5
    t=$((t + 5))
    printf '.'
    if "$@"; then printf ' ready\n'; return 0; fi
  done
  printf ' gave up after %ss\n' "$limit"
  return 1
}

component_desc() {
  case "$1" in
    intellij) echo "IntelliJ IDEA Ultimate with its plugins and the VA code style (license from Amy Calvert)" ;;
    postman) echo "Postman (API testing)" ;;
    headlamp) echo "Headlamp (Kubernetes UI)" ;;
    plantuml) echo "PlantUML and Graphviz (diagrams)" ;;
    jira) echo "Jira CLI (tickets without the web UI)" ;;
    jenkins) echo "Jenkins CLI (Sandbox builds without the web UI)" ;;
    githooks) echo "Global git hooks with TruffleHog secret scanning" ;;
    macos) echo "macOS: smart quotes and dashes off, hidden files shown in Finder" ;;
    iterm2) echo "iTerm2" ;;
    sublime) echo "Sublime Text" ;;
    avd) echo "Windows App (Azure Virtual Desktop; needs your PIV)" ;;
    citrix) echo "Citrix Workspace (VA Citrix Access Gateway; needs your PIV)" ;;
  esac
}

cask_apps() { # cask -> app bundle names, |-separated
  case "$1" in
    orbstack) echo "OrbStack.app" ;;
    docker-desktop) echo "Docker.app" ;;
    headlamp) echo "Headlamp.app" ;;
    postman) echo "Postman.app" ;;
    intellij-idea) echo "IntelliJ IDEA.app|IntelliJ IDEA Ultimate.app" ;;
    iterm2) echo "iTerm.app" ;;
    sublime-text) echo "Sublime Text.app" ;;
    windows-app) echo "Windows App.app|Microsoft Remote Desktop.app" ;;
    citrix-workspace) echo "Citrix Workspace.app" ;;
  esac
}
app_path() { # cask -> where its app is installed (in APP_DIRS: ~/Applications, /Applications)
  local app d old_ifs="$IFS" names
  names="$(cask_apps "$1")"
  IFS=:
  for d in $APP_DIRS; do
    IFS='|'
    for app in $names; do
      if [ -n "$d" ] && [ -d "$d/$app" ]; then IFS="$old_ifs"; printf '%s' "$d/$app"; return 0; fi
    done
    IFS=:
  done
  IFS="$old_ifs"
  return 1
}
app_present() { app_path "$1" >/dev/null; } # cask -> 0 if its app is installed

# --- HTTP checks (secrets go to curl on stdin, never on the command line) -------------------
http_code() { local c; c="$(curl -s -o /dev/null -w '%{http_code}' --max-time "${2:-20}" "$1" 2>/dev/null)"; printf '%s' "${c:-000}"; } # URL [SECONDS]
curl_quote() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
http_code_basic() { # USER SECRET URL
  local c
  c="$(printf 'user = "%s"\n' "$(curl_quote "$1:$2")" | curl -K - -s -o /dev/null -w '%{http_code}' --max-time 20 "$3" 2>/dev/null)"
  printf '%s' "${c:-000}"
}
http_code_bearer() { # TOKEN URL
  local c
  c="$(printf 'header = "Authorization: Bearer %s"\n' "$(curl_quote "$1")" | curl -K - -s -o /dev/null -w '%{http_code}' --max-time 20 "$2" 2>/dev/null)"
  printf '%s' "${c:-000}"
}

# --- Credentials ----------------------------------------------------------------------------
cred_meta() { # ID -> C_TITLE C_HINT C_UVAR C_SVAR C_URL
  case "$1" in
    bitbucket) C_TITLE="Bitbucket (Coderepo)" C_UVAR=VA_BITBT_USER C_SVAR=VA_BITBT_PWD C_URL="$BITBUCKET_CHECK_URL"
      C_HINT="Crowd username and Bitbucket HTTP access token (git clone, pull, push)" ;;
    nexus) C_TITLE="VA Nexus" C_UVAR=VA_NEXUS_USER C_SVAR=VA_NEXUS_PWD C_URL="$NEXUS_CHECK_URL"
      C_HINT="User Token name and passcode from nexus.mobilehealth.va.gov, not your Crowd login (Maven)" ;;
    dtr) C_TITLE="Sandbox DTR" C_UVAR=DTR_USER C_SVAR=DTR_PWD C_URL="$DTR_CHECK_URL"
      C_HINT="User Token name and passcode from nexus.mapsandbox.net, not your Sandbox login (Docker images)" ;;
    jira) C_TITLE="Jira" C_UVAR=JIRA_LOGIN C_SVAR=JIRA_API_TOKEN C_URL="$JIRA_SERVER/rest/api/2/myself"
      C_HINT="Crowd username and Jira personal access token (Jira CLI)" ;;
    jenkins) C_TITLE="Sandbox Jenkins" C_UVAR=JENKINS_API_USER C_SVAR=JENKINS_API_TOKEN C_URL="$JENKINS_URL/whoAmI/api/json"
      C_HINT="Sandbox username and Jenkins API token (Jenkins CLI)" ;;
  esac
}

check_credential() { # ID USER SECRET -> 0 accepted, 1 rejected, 2 couldn't tell, 3 Maven-encrypted
  cred_meta "$1"
  CHECK_HOST="${C_URL#https://}"
  CHECK_HOST="${CHECK_HOST%%/*}"
  if is_encrypted "$3"; then CHECK_CODE="Maven-encrypted"; return 3; fi
  if [ "$1" = jira ]; then CHECK_CODE="$(http_code_bearer "$3" "$C_URL")"; else CHECK_CODE="$(http_code_basic "$2" "$3" "$C_URL")"; fi
  case "$CHECK_CODE" in 2??) return 0 ;; 401 | 403) return 1 ;; *) return 2 ;; esac
}

jira_login() { sed -n 's/^login: *//p' "$JIRA_CONFIG" 2>/dev/null | head -n 1; }

cred_summary() { # services that have a username and token set
  local out="" id u
  for id in bitbucket nexus dtr jira jenkins; do
    cred_meta "$id"
    u="${!C_UVAR:-}"
    [ "$id" = jira ] && u="x"
    if [ -n "$u" ] && [ -n "${!C_SVAR:-}" ]; then out="$out, $C_TITLE"; fi
  done
  printf '%s' "${out#, }"
}

load_credentials() { # values in credentials.env win over the inherited environment
  [ -f "$CRED_FILE" ] || return 0
  set +u
  # shellcheck disable=SC1090
  . "$CRED_FILE"
  set -u
}

load_settings() { # the workspace install saved; --workspace, read later, still wins for one run
  local ws
  ws="$(sed -n 's/^workspace=//p' "$SETTINGS_FILE" 2>/dev/null | tail -n 1)"
  case "$ws" in /*) WORKSPACE_DIR="$ws" ;; esac
}
save_settings() { # remember the workspace, so doctor, repos, clone, tidy and upgrade look there too
  local tmp
  [ "$DRY_RUN" != 1 ] || return 0
  [ "$(sed -n 's/^workspace=//p' "$SETTINGS_FILE" 2>/dev/null | tail -n 1)" != "$WORKSPACE_DIR" ] || return 0
  (umask 077 && mkdir -p "$NGSS_DIR") && chmod 700 "$NGSS_DIR" || return 0
  tmp="$(umask 077 && mktemp "$NGSS_DIR/.settings.XXXXXX")" || return 0
  { grep -v '^workspace=' "$SETTINGS_FILE" 2>/dev/null; printf 'workspace=%s\n' "$WORKSPACE_DIR"; } >"$tmp" && mv -f "$tmp" "$SETTINGS_FILE" || rm -f "$tmp"
}

import_credentials() { # FILE of KEY=VALUE lines; read as data, never executed
  local f="$1" shown line key val n=0 num=0 cr=$'\r' sq="'" dq='"' bom=$'\xef\xbb\xbf'
  [ -r "$f" ] || die "Can't read $f"
  shown="$(tildify "$f")"
  while IFS= read -r line || [ -n "$line" ]; do
    num=$((num + 1))
    line="${line%"$cr"}"
    [ "$num" != 1 ] || line="${line#"$bom"}" # a byte-order mark, from some Windows editors
    line="$(trim "$line")"
    case "$line" in "" | "#"*) continue ;; esac
    line="${line#export }"
    case "$line" in *=*) ;; *) warn "ignored line $num of $shown, which isn't KEY=VALUE"; continue ;; esac
    key="$(trim "${line%%=*}")"
    val="$(trim "${line#*=}")"
    case "$val" in # like the shell: "quoted", 'quoted', or bare up to a " # comment"
      "$dq"*) val="${val#"$dq"}" val="${val%%"$dq"*}" ;;
      "$sq"*) val="${val#"$sq"}" val="${val%%"$sq"*}" ;;
      *) val="$(trim "${val%%[[:space:]]#*}")" ;;
    esac
    case " $CRED_VARS JIRA_LOGIN " in
      *" $key "*) printf -v "$key" '%s' "$val"; export "${key?}"; n=$((n + 1)) ;;
      *) if [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then warn "ignored unknown key '$key' in $shown"
         else warn "ignored line $num of $shown, which isn't KEY=VALUE"; fi ;; # it could be part of a token
    esac
  done <"$f"
  ok "read $n value(s) from $shown"
}

maybe_delete_import_file() {
  [ -n "$CRED_IMPORT_FILE" ] && [ -f "$CRED_IMPORT_FILE" ] && [ "$DRY_RUN" != 1 ] || return 0
  if [ "$ASSUME_YES" = 1 ]; then warn "delete $(tildify "$CRED_IMPORT_FILE") when you're done; it holds secrets"; return 0; fi
  if ask_yn "    Delete $(tildify "$CRED_IMPORT_FILE")? Its secrets are now in credentials.env." Y; then
    rm -f "$CRED_IMPORT_FILE" && ok "deleted $(tildify "$CRED_IMPORT_FILE")"
  fi
}

prompt_credential() { # ID: ask, try it against the server, keep it if accepted
  local id="$1" u s cur_u cur_s keep tries=0 rc
  cred_meta "$id"
  cur_u="${!C_UVAR:-}"
  cur_s="${!C_SVAR:-}"
  if [ "$id" = jira ] && [ -z "$cur_u" ]; then cur_u="$(jira_login)"; [ -n "$cur_u" ] || cur_u="${VA_BITBT_USER:-}"; fi
  while :; do
    printf '\n  %s%s%s  %s%s%s\n' "$B" "$C_TITLE" "$RST" "$DIM" "$C_HINT" "$RST" >"$TTY"
    u="$(ask "    Username" "$cur_u")"
    keep=""
    [ -z "$cur_s" ] || keep=" (Enter keeps the current one)"
    s="$(ask_secret "    Token$keep")"
    [ -n "$s" ] || s="$cur_s"
    if [ -z "$u" ] || [ -z "$s" ]; then skip "$C_TITLE: skipped"; return 0; fi
    check_credential "$id" "$u" "$s"
    rc=$?
    case $rc in
      0) ok "$C_TITLE: accepted by $CHECK_HOST"; break ;;
      2) warn "$C_TITLE: couldn't reach $CHECK_HOST to check it (HTTP $CHECK_CODE); keeping it"; break ;;
      3) info "$C_TITLE: Maven-encrypted, so only Maven can use it; not checked"; break ;;
    esac
    bad "$C_TITLE: rejected by $CHECK_HOST (HTTP $CHECK_CODE)"
    tries=$((tries + 1))
    if [ "$tries" -ge 3 ] || ! ask_yn "    Try again?" Y; then
      ask_yn "    Save it anyway?" N && break
      return 0
    fi
  done
  printf -v "$C_UVAR" '%s' "$u"
  printf -v "$C_SVAR" '%s' "$s"
  export "${C_UVAR?}" "${C_SVAR?}"
}

has_credential() { cred_meta "$1"; [ -n "${!C_SVAR:-}" ] && { [ "$1" = jira ] || [ -n "${!C_UVAR:-}" ]; }; } # ID
gather_credentials() { # IDS...: on install, the ones you have are asked again only if you want; missing ones always
  local id have="" missing="" ask_ids="$*"
  [ "$ASSUME_YES" = 1 ] && return 0
  if [ "$COMMAND" = install ]; then
    for id in "$@"; do if has_credential "$id"; then have="$have $id"; else missing="$missing $id"; fi; done
    if [ -n "$have" ]; then
      # shellcheck disable=SC2086
      say "Credentials you already have: $(cred_titles $have)"
      if [ -z "$missing" ]; then ask_yn "    Re-enter any of them?" N || return 0
      elif ! ask_yn "    Re-enter those too? (No asks only for the missing ones)" N; then ask_ids="$missing"; fi
    fi
  fi
  printf '\n  %sCredentials%s: use the usernames and tokens from the onboarding team, or your own.\n' "$B" "$RST" >"$TTY"
  printf '  Press Enter to skip any you do not have yet. Tokens stay hidden while you type.\n' >"$TTY"
  for id in $ask_ids; do prompt_credential "$id"; done
}

shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
cred_file_content() {
  local v val
  echo "# NGSS development credentials, written by $ME and loaded by your shell profile."
  echo "# Keep this file private (chmod 600) and never commit it. To change values run: $ME credentials"
  for v in $CRED_VARS JIRA_LOGIN; do # JIRA_LOGIN isn't secret, but without it the Jira username is lost when jira-cli isn't set up
    val="${!v:-}"
    [ -z "$val" ] || printf 'export %s=%s\n' "$v" "$(shell_quote "$val")"
  done
}
write_credentials() {
  local tmp
  if [ "$DRY_RUN" = 1 ]; then dry "write $(tildify "$CRED_FILE") (chmod 600) for: $(cred_summary)"; return 0; fi
  (umask 077 && mkdir -p "$NGSS_DIR") || return 1
  chmod 700 "$NGSS_DIR"
  tmp="$(umask 077 && mktemp "$NGSS_DIR/.credentials.XXXXXX")" || { bad "couldn't make temporary files (is the disk full?); left $(tildify "$CRED_FILE") as it was"; return 1; }
  cred_file_content >"$tmp" || { rm -f "$tmp"; bad "couldn't write a temporary file (is the disk full?); left $(tildify "$CRED_FILE") as it was"; return 1; }
  if [ -f "$CRED_FILE" ] && cmp -s "$tmp" "$CRED_FILE"; then
    rm -f "$tmp"
    ok "$(tildify "$CRED_FILE") is up to date"
    return 0
  fi
  chmod 600 "$tmp" && mv -f "$tmp" "$CRED_FILE" && ok "saved $(tildify "$CRED_FILE") (chmod 600): $(cred_summary)"
}

# --- Shell profile --------------------------------------------------------------------------
rc_files() { echo "$ZDIR/.zshrc"; case "${SHELL:-}" in */bash) echo "$HOME/.bash_profile" ;; esac; }
find_zdir() { # -> the folder zsh reads .zshrc and .zprofile from, asking zsh itself, since ~/.zshenv may set ZDOTDIR
  local d
  # shellcheck disable=SC2016 # zsh expands it, after reading ~/.zshenv
  d="$(run_limited 10 /bin/zsh -c 'print -r -- "${ZDOTDIR:-$HOME}"' </dev/null 2>/dev/null | tail -n 1)"
  [ -n "$d" ] && [ -d "$d" ] || d="$HOME"
  printf '%s' "${d%/}"
}
chain_bash_profile() { # bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile, so when this run created
  # ~/.bash_profile (SDKMAN's installer creates it too), have it load the file bash read before
  local f="$HOME/.bash_profile" prev
  case "${SHELL:-}" in */bash) ;; *) return 0 ;; esac
  [ "$BASH_PROFILE_NEW" = 1 ] && [ -f "$f" ] || return 0
  if [ -f "$HOME/.bash_login" ]; then prev=.bash_login; elif [ -f "$HOME/.profile" ]; then prev=.profile; else return 0; fi
  ! grep -qF "\$HOME/$prev" "$f" || return 0
  if [ "$DRY_RUN" = 1 ]; then dry "make ~/.bash_profile load ~/$prev first, which bash read before"; return 0; fi
  # shellcheck disable=SC2016 # the line is for the profile
  if { printf '# bash reads only the first of ~/.bash_profile, ~/.bash_login and ~/.profile; keep loading ~/%s\n[ -f "$HOME/%s" ] && . "$HOME/%s"\n\n' "$prev" "$prev" "$prev"; cat "$f"; } >"$f.ngss-new" && mv "$f.ngss-new" "$f"; then
    ok "~/.bash_profile now loads ~/$prev first, which bash read before ~/.bash_profile existed"
  else rm -f "$f.ngss-new"; bad "couldn't add a line to ~/.bash_profile that loads ~/$prev; add it yourself: . ~/$prev"; return 1; fi
}
profile_files() {
  local f
  for f in .zshenv .zprofile .zshrc .zlogin .bash_profile .bashrc .profile; do
    [ ! -f "$HOME/$f" ] || printf '%s\n' "$HOME/$f"
  done
  [ "$ZDIR" = "$HOME" ] || for f in .zshenv .zprofile .zshrc .zlogin; do [ ! -f "$ZDIR/$f" ] || printf '%s\n' "$ZDIR/$f"; done
}

# shellcheck disable=SC2016 # the $HOME and "$@" below belong in the profile, unexpanded
managed_block() {
  echo "$MARK_BEGIN"
  echo "# Added by $ME. Re-run it to change these; edits between the markers are overwritten."
  echo "export SKAFFOLD_PUSH=false"
  echo "export SKAFFOLD_RPC_PORT=50051"
  echo "export DTR_URL=$DTR_HOST"
  echo '# Bitbucket, VA Nexus, Sandbox DTR, Jira and Jenkins credentials (private file, chmod 600)'
  echo 'if [ -r "$HOME/.config/ngss/credentials.env" ]; then . "$HOME/.config/ngss/credentials.env"; fi'
  if selected jenkins || [ -f "$JENKINS_JAR" ] || [ -n "${JENKINS_API_TOKEN:-}" ]; then
    echo 'export JENKINS_CLI_JAR="$HOME/.jenkins-cli/jenkins-cli.jar"'
    echo "export JENKINS_API_URL=\"$JENKINS_URL\""
    echo '# The Jenkins CLI reads JENKINS_USER_ID and JENKINS_API_TOKEN from the environment, keeping the token off its command line'
    echo 'jcli() { JENKINS_USER_ID="$JENKINS_API_USER" java -jar "$JENKINS_CLI_JAR" -s "$JENKINS_API_URL" "$@"; }'
  fi
  echo "$MARK_END"
}

markers_ok() { # FILE: 1 if its ngss-dev-setup block has no end marker, 2 if an end marker has no start
  [ -f "$1" ] || return 0
  awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
    $0 == b { if (s) open = 1; s = 1; next }
    $0 == e { if (!s) stray = 1; s = 0 }
    END { if (s || open) exit 1; if (stray) exit 2 }' "$1"
}
markers_problem() { # FILE -> what's wrong with its block's markers, for messages
  markers_ok "$1"
  case $? in
    0) ;;
    1) printf '%s: the ngss-dev-setup block has no end marker (%s); put it back after the block, or delete the block' "$(tildify "$1")" "$MARK_END" ;;
    *) printf '%s: an ngss-dev-setup end marker (%s) has no start; delete it, or put %s back before the block' "$(tildify "$1")" "$MARK_END" "$MARK_BEGIN" ;;
  esac
}
write_block() { # FILE: add or refresh the managed block, keeping SDKMAN's lines last
  local f="$1" blk="" cur="" new="" where rc=0 problem
  problem="$(markers_problem "$f")"
  if [ -n "$problem" ]; then bad "$problem, then run this again"; return 1; fi # rewriting it could drop the lines after it
  if ! { blk="$(mktemp)" && cur="$(mktemp)" && new="$(mktemp)"; }; then
    rm -f "$blk" "$cur" "$new"
    bad "couldn't make temporary files (is the disk full?); left $(tildify "$f") as it was"
    return 1
  fi
  if ! managed_block >"$blk" || { [ -f "$f" ] && ! cat "$f" >"$cur"; } || ! awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
    $0 == b { skip = 1; next }
    skip && $0 == e { skip = 0; drop = 1; next }
    skip { next }
    drop && $0 == "" { drop = 0; next }
    { drop = 0; print }' "$cur" |
    awk -v blk="$blk" '
      function emit(  l) { while ((getline l < blk) > 0) print l; close(blk) }
      !done && /^#THIS MUST BE AT THE END OF THE FILE FOR SDKMAN TO WORK/ { emit(); print ""; done = 1 }
      { print; last = $0 }
      END { if (!done) { if (NR > 0 && last != "") print ""; emit() } }' >"$new" || [ ! -s "$new" ]; then
    rm -f "$blk" "$cur" "$new"
    bad "couldn't prepare the new $(tildify "$f") (is the disk full?); left it as it was"
    return 1
  fi
  if cmp -s "$cur" "$new"; then
    ok "$(tildify "$f") is up to date"
  elif [ "$DRY_RUN" = 1 ]; then
    # Show only the block itself: printing the profile around it could show tokens set next to it.
    where="at the end"
    if grep -q '^#THIS MUST BE AT THE END OF THE FILE FOR SDKMAN TO WORK' "$cur"; then where="above SDKMAN's lines"; fi
    if grep -qxF "$MARK_BEGIN" "$cur"; then dry "replace the ngss-dev-setup block in $(tildify "$f") with this, $where:"
    else dry "add this block to $(tildify "$f"), $where:"; fi
    sed 's/^/          /' "$blk"
  elif ! backup "$f"; then
    bad "couldn't back up $(tildify "$f") (is the disk full?); left it as it was"
    rc=1
  elif cat "$new" >"$f"; then
    ok "updated $(tildify "$f")"
    [ "$f" != "$HOME/.bash_profile" ] || chain_bash_profile || rc=1
  else
    cat "$cur" >"$f" 2>/dev/null
    bad "couldn't write $(tildify "$f"); put back what was there"
    rc=1
  fi
  rm -f "$blk" "$cur" "$new"
  return $rc
}

assign_regex() { printf '^[[:space:]]*(export[[:space:]]+)?(%s)=' "$(printf '%s' "$1" | tr ' ' '|')"; }
outside_lines() { # FILE "VARS" -> comma-separated lines that set VARS outside the managed block
  local block=1
  markers_ok "$1" || block=0 # a damaged block: every line counts as outside it
  awk -v b="$MARK_BEGIN" -v e="$MARK_END" -v re="$(assign_regex "$2")" -v block="$block" '
    block && $0 == b { s = 1; next }
    block && $0 == e { s = 0; next }
    s { next }
    $0 ~ re { printf "%s%d", (n++ ? "," : ""), NR }' "$1"
}
outside_defs() { # "VARS" -> "~/.zshrc:12,40 ~/.bashrc:3"
  local f lines out=""
  while IFS= read -r f <&3; do
    [ -n "$f" ] || continue # no profile files: one empty line, and awk given "" would read the terminal
    lines="$(outside_lines "$f" "$1")"
    [ -z "$lines" ] || out="$out $(tildify "$f"):$lines"
  done 3<<EOF
$(profile_files)
EOF
  printf '%s' "${out# }"
}
profile_parses() { case "${1##*/}" in .z*) zsh -n "$1" ;; *) bash -n "$1" ;; esac >/dev/null 2>&1; } # FILE
disable_outside_creds() { # [forget|noask|ask] [VARS]: neutralize credential exports outside the managed block; 2 if one is left
  local f lines tmp pre found parsed rc=0 vars="${2:-$CRED_VARS}" mark=": # credential moved to ~/.config/ngss/credentials.env by ngss-dev-setup"
  found="$(outside_defs "$vars")"
  [ -n "$found" ] || return 0
  if [ "${1:-}" = forget ]; then
    mark=": # credential removed by ngss-dev-setup forget"
  elif [ "${1:-}" != noask ]; then # noask: doctor --fix already asked
    warn "credentials are also set directly in: $found"
    note "Whichever line runs last wins, so old values there can override credentials.env."
    ask_yn "    Disable those lines? (a private backup is kept)" Y || return 0
  fi
  while IFS= read -r f <&3; do
    lines="$(outside_lines "$f" "$vars")"
    [ -n "$lines" ] || continue
    if ! markers_ok "$f"; then warn "left line(s) $lines in $(tildify "$f") alone: $(markers_problem "$f")"; [ "$rc" = 1 ] || rc=2; continue; fi
    if [ "$DRY_RUN" = 1 ]; then dry "disable line(s) $lines in $(tildify "$f")"; continue; fi
    parsed=0
    ! profile_parses "$f" || parsed=1
    tmp="" pre=""
    if ! { tmp="$(mktemp)" && pre="$(mktemp)"; } || ! cat "$f" >"$pre"; then
      rm -f "$tmp" "$pre"
      bad "couldn't make temporary files (is the disk full?); left $(tildify "$f") as it was"
      rc=1
      continue
    fi
    if ! backup "$f"; then
      bad "couldn't back up $(tildify "$f") (is the disk full?); left it as it was"
      rc=1
    elif ! awk -v ln=",$lines," -v mark="$mark" '
      index(ln, "," NR ",") { match($0, /^[[:space:]]*/); print substr($0, 1, RLENGTH) mark; next }
      { print }' "$f" >"$tmp"; then
      bad "couldn't write a temporary file (is the disk full?); left $(tildify "$f") as it was"
      rc=1
    elif ! cat "$tmp" >"$f"; then
      cat "$pre" >"$f" 2>/dev/null
      bad "couldn't write $(tildify "$f"); put back what was there"
      rc=1
    elif [ "$parsed" = 1 ] && ! profile_parses "$f"; then # e.g. a value continued on the next line
      cat "$pre" >"$f"
      warn "disabling line(s) $lines would break $(tildify "$f"), so it was left as it was; remove those lines by hand"
      [ "$rc" = 1 ] || rc=2
    else
      ok "disabled line(s) $lines in $(tildify "$f")"
    fi
    rm -f "$tmp" "$pre"
  done 3<<EOF
$(profile_files)
EOF
  return $rc
}

# --- Xcode Command Line Tools and Homebrew ----------------------------------------------------
brew_bin() { # the Homebrew to use; on Apple Silicon only the native one, never an Intel copy left in /usr/local
  local b
  if arm_mac; then [ -x "$BREW_ARM/bin/brew" ] && echo "$BREW_ARM/bin/brew"; return; fi
  if has brew; then command -v brew; return 0; fi
  for b in "$BREW_ARM/bin/brew" "$BREW_X86/bin/brew"; do
    if [ -x "$b" ]; then echo "$b"; return 0; fi
  done
  return 1
}
login_profile() { case "${SHELL:-}" in */bash) echo "$HOME/.bash_profile" ;; *) echo "$ZDIR/.zprofile" ;; esac; }
brew_line_last() { # 0 if the last brew shellenv line in the login profile is Apple Silicon's, so new terminals find its Homebrew first
  local last
  last="$(grep -v '^[[:space:]]*#' "$(login_profile)" 2>/dev/null | grep -F 'brew shellenv' | tail -n 1)"
  case "$last" in *"$BREW_ARM/bin/brew shellenv"* | *"$(printf '%q' "$BREW_ARM/bin/brew") shellenv"*) return 0 ;; esac
  return 1
}
add_brew_line() { # put Apple Silicon's Homebrew first in new terminals, after any line for an Intel copy
  local f
  f="$(login_profile)"
  if brew_line_last; then ok "$(tildify "$f") sets up Homebrew from $BREW_ARM"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "add Homebrew from $BREW_ARM to the end of $(tildify "$f")"; return 0; fi
  backup "$f"
  # shellcheck disable=SC2016 # the line is for the profile
  if printf '\neval "$(%q shellenv)"\n' "$BREW_ARM/bin/brew" >>"$f"; then ok "added Homebrew from $BREW_ARM to $(tildify "$f")"; else bad "couldn't write $(tildify "$f")"; return 1; fi
  chain_bash_profile
}

step_clt() {
  if clt_ready; then ok "installed at $(xcode-select -p)"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "xcode-select --install"; return 0; fi
  say "A macOS dialog opens: click Install and accept the license. Waiting for it to finish..."
  xcode-select --install >/dev/null 2>&1
  wait_until 3600 "the Command Line Tools" clt_ready || { NOTE="not installed"; return 1; }
  ok "installed"
}

step_homebrew() {
  local b
  b="$(brew_bin)"
  if [ -z "$b" ]; then
    if [ "$DRY_RUN" = 1 ]; then dry "install Homebrew from https://brew.sh"; return 0; fi
    say "Installing Homebrew. It asks for your Mac password."
    sudo -v || { NOTE="needs an administrator account"; return 1; }
    NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || return 1
    b="$(brew_bin)" || return 1
  fi
  eval "$("$b" shellenv)"
  [ "$b" != "$BREW_ARM/bin/brew" ] || add_brew_line # Intel Homebrew's /usr/local/bin is on macOS's PATH already
  ok "$(brew --version 2>/dev/null | head -n 1)"
}

formula_cmd() { case "$1" in jira-cli) echo jira ;; kubernetes-cli) echo kubectl ;; graphviz) echo dot ;; *) echo "$1" ;; esac; }
plan_formulas() {
  local l="$BREW_FORMULAS"
  selected plantuml && l="$l plantuml"
  selected jira && l="$l jira-cli"
  printf '%s' "$l"
}
plan_casks() {
  local l=""
  case "$RUNTIME" in orbstack) l="orbstack" ;; docker-desktop) l="docker-desktop" ;; esac
  selected headlamp && l="$l headlamp"
  selected intellij && l="$l intellij-idea"
  selected postman && l="$l postman"
  selected iterm2 && l="$l iterm2"
  selected sublime && l="$l sublime-text"
  selected avd && l="$l windows-app"
  selected citrix && l="$l citrix-workspace"
  printf '%s' "${l# }"
}

brew_install_formulae() { # FORMULAE...: brew install them; on Intel, warn first that they build from source (2 if skipped)
  [ $# -gt 0 ] || return 0
  if brew_intel; then
    warn "Homebrew stopped making prebuilt packages for Intel Macs in September 2026, so $(commas "$@") may have to build from source; that can take hours"
    case " $* " in *" skaffold "* | *" kustomize "*) note "Skaffold and Kustomize also come as ready-made downloads: see the Skaffold and Kustomize Setup page." ;; esac
    if [ "$DRY_RUN" != 1 ] && ! ask_yn "    Build $(commas "$@") from source?" Y; then skip "not installed: $(commas "$@")"; return 2; fi
  fi
  run brew install "$@"
}

step_brew_packages() {
  local f c have="" todo_f="" todo_c="" rc=0 r
  for f in $(plan_formulas); do if has "$(formula_cmd "$f")"; then have="$have $f"; else todo_f="$todo_f $f"; fi; done
  for c in $(plan_casks); do if app_present "$c"; then have="$have $c"; else todo_c="$todo_c $c"; fi; done
  [ -z "$have" ] || ok "already installed:$have"
  if [ -n "$todo_f" ]; then
    say "Installing:$todo_f"
    # shellcheck disable=SC2086 # word splitting is intended
    brew_install_formulae $todo_f
    r=$?
    if [ "$r" = 2 ]; then NOTE="Intel Mac: tools not built" rc=2 todo_f=""; elif [ "$r" != 0 ]; then rc=1; fi
  fi
  # shellcheck disable=SC2086
  if [ -n "$todo_c" ]; then say "Installing apps:$todo_c"; run brew install --cask $todo_c || rc=1; fi
  [ "$DRY_RUN" != 1 ] || return 0
  for f in $todo_f; do if has "$(formula_cmd "$f")"; then ok "$f"; else bad "$f didn't install"; rc=1; fi; done
  for c in $todo_c; do if app_present "$c"; then ok "$c"; else bad "$c didn't install"; rc=1; fi; done
  unquarantine_headlamp
  return $rc
}

unquarantine_headlamp() { # per K8s Monitoring Tools: Headlamp may be unsigned
  local app
  app="$(app_path headlamp)" && xattr -p com.apple.quarantine "$app" >/dev/null 2>&1 || return 0
  ! spctl --assess --type execute "$app" >/dev/null 2>&1 || return 0
  xattr -dr com.apple.quarantine "$app" 2>/dev/null || sudo xattr -dr com.apple.quarantine "$app"
}

# --- Git --------------------------------------------------------------------------------------
git_quiet() { env GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never GIT_ASKPASS= SSH_ASKPASS= git -c core.askPass= "$@"; }
coderepo_saved() { # git's saved Coderepo login as "credential fill" prints it; empty if none
  printf 'protocol=https\nhost=%s\n\n' "$CODEREPO_HOST" | git_quiet credential fill 2>/dev/null
}
cred_field() { # TEXT FIELD -> value of the FIELD= line
  local nl=$'\n' v
  case "$1" in *"$2="*) ;; *) return 0 ;; esac
  v="${1#*"$2"=}"
  printf '%s' "${v%%"$nl"*}"
}
git_set() { # KEY VALUE (global config)
  if [ "$(git config --global --get "$1" 2>/dev/null)" = "$2" ]; then ok "$1 = $2"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "git config --global $1 $2"; return 0; fi
  git config --global "$1" "$2" && ok "$1 = $2"
}
git_forget_coderepo() { # drop every saved Coderepo login (keychain and any other helper); 0 if none remain
  local saved i=0
  saved="$(coderepo_saved)"
  while [ -n "$saved" ] && [ "$i" -lt 5 ]; do
    printf '%s\n\n' "$saved" | git credential reject
    saved="$(coderepo_saved)"
    i=$((i + 1))
  done
  [ -z "$saved" ]
}
git_save_coderepo() { # USER TOKEN: replace whatever git has saved for Coderepo
  local saved
  if is_encrypted "$2"; then warn "VA_BITBT_PWD is Maven-encrypted, and git needs the plain token, so git's saved login was left alone"; return 2; fi
  saved="$(coderepo_saved)"
  if [ "$(cred_field "$saved" username)" = "$1" ] && [ "$(cred_field "$saved" password)" = "$2" ]; then
    ok "git's saved $CODEREPO_HOST login is current"
    return 0
  fi
  if [ "$DRY_RUN" = 1 ]; then dry "replace git's saved $CODEREPO_HOST login (macOS keychain)"; return 0; fi
  git_forget_coderepo # drop old logins, such as an onboarding token
  printf 'protocol=https\nhost=%s\nusername=%s\npassword=%s\n\n' "$CODEREPO_HOST" "$1" "$2" | git credential approve
  ok "saved the Bitbucket token as git's $CODEREPO_HOST login (helper: $(git config --get-all credential.helper | paste -sd ' ' -))"
}
step_git() {
  local rc=0 r
  git_ready || { bad "git doesn't work yet; it comes with the Command Line Tools"; return 1; }
  ok "$(git --version)"
  if [ -n "$GIT_NAME" ]; then git_set user.name "$GIT_NAME"; else warn "user.name not set: git config --global user.name \"Your Name\""; rc=2; fi
  if [ -n "$GIT_EMAIL" ]; then git_set user.email "$GIT_EMAIL"; else warn "user.email not set: git config --global user.email <you>@apothesource.com"; rc=2; fi
  git_set core.trustctime false
  [ -n "$(git config --get-all credential.helper 2>/dev/null)" ] || git_set credential.helper osxkeychain
  if [ -n "${VA_BITBT_USER:-}" ] && [ -n "${VA_BITBT_PWD:-}" ]; then
    git_save_coderepo "$VA_BITBT_USER" "$VA_BITBT_PWD"
    r=$?
    [ "$r" -le "$rc" ] || rc=$r
  else
    warn "no Bitbucket credentials yet, so cloning from Coderepo fails until you run: $ME credentials"
    rc=2
  fi
  return $rc
}

# --- SDKMAN, Java, Maven, Gradle ------------------------------------------------------------
sdk_init() {
  [ -s "$SDKMAN_DIR/bin/sdkman-init.sh" ] || return 1
  type sdk >/dev/null 2>&1 && return 0
  export SDKMAN_DIR
  set +u +o pipefail
  # shellcheck disable=SC1091
  . "$SDKMAN_DIR/bin/sdkman-init.sh" >/dev/null 2>&1
  set -u -o pipefail
  type sdk >/dev/null 2>&1
}
sdk_run() { local rc; set +u +o pipefail; sdk "$@"; rc=$?; set -u -o pipefail; return $rc; }
sdk_install() { # CANDIDATE [VERSION], keeping the current default even when sdkman_auto_answer=true in SDKMAN's config
  local before rc
  before="$(sdk_current "$1")"
  printf 'n\n' | sdk_run install "$@" # "n": don't make it the default
  rc=$?
  if [ -n "$before" ] && [ "$(sdk_current "$1")" != "$before" ]; then sdk_run default "$1" "$before" >/dev/null 2>&1; fi
  return $rc
}
sdk_has() { [ -d "$SDKMAN_DIR/candidates/$1/$2" ]; }
sdkman_version() { cat "$SDKMAN_DIR/var/version" 2>/dev/null; } # not from `sdk version`, whose format changed in SDKMAN 5.24
sdk_current() { local l; l="$(readlink "$SDKMAN_DIR/candidates/$1/current" 2>/dev/null)" || return 0; basename "$l"; }
sdk_platform() { cat "$SDKMAN_DIR/var/platform" 2>/dev/null; }
sdk_platform_wrong() { arm_mac && [ "$(sdk_platform)" = darwinx64 ]; } # an Intel SDKMAN on Apple Silicon: copied by Migration Assistant, or installed under Rosetta
jdk_arch() { # VERSION -> aarch64 or x86_64, from the JDK's release file or its java binary; empty if it can't tell
  local d="$SDKMAN_DIR/candidates/java/$1" a
  a="$(sed -n 's/^OS_ARCH="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$d/release" 2>/dev/null | head -n 1)"
  [ -n "$a" ] || a="$(lipo -archs "$d/bin/java" 2>/dev/null)"
  case "$a" in *aarch64* | *arm64*) echo aarch64 ;; *x86_64*) echo x86_64 ;; esac
}
installed_jdks() { local d; for d in "$SDKMAN_DIR/candidates/java"/*; do [ ! -d "$d" ] || [ -L "$d" ] || basename "$d"; done; }
fix_sdk_platform() { # make SDKMAN fetch Apple Silicon builds, and reinstall its JDKs, which are Intel builds
  local v cur rc=0
  sdk_init || { bad "SDKMAN isn't installed"; return 1; }
  if [ "$DRY_RUN" = 1 ]; then dry "set SDKMAN's platform to darwinarm64, and reinstall its JDKs as Apple Silicon builds: $(installed_jdks | paste -sd ' ' -)"; return 0; fi
  printf 'darwinarm64\n' >"$SDKMAN_DIR/var/platform" || { bad "couldn't write $(tildify "$SDKMAN_DIR/var/platform")"; return 1; }
  export SDKMAN_PLATFORM=darwinarm64 # SDKMAN read the file once, when sdk_init loaded it, and downloads for this
  ok "SDKMAN now fetches Apple Silicon builds (darwinarm64)"
  cur="$(sdk_current java)"
  for v in $(installed_jdks); do
    # Only remove an Intel JDK once SDKMAN confirms an Apple Silicon build of it exists, so it can't be left without one
    if ! sdk_offered java "$v"; then warn "kept the Intel build of java $v: SDKMAN has no Apple Silicon build of it (or couldn't be reached)"; rc=2; continue; fi
    say "Reinstalling java $v as an Apple Silicon build..."
    if ! { sdk_run uninstall java "$v" </dev/null >/dev/null 2>&1 && sdk_install java "$v" >/dev/null 2>&1 && sdk_has java "$v"; }; then
      bad "couldn't reinstall java $v; open a new terminal and run: sdk install java $v"; rc=1
    elif [ "$(jdk_arch "$v")" = x86_64 ]; then
      bad "java $v is still an Intel build after reinstalling; open a new terminal and run: sdk uninstall java $v && sdk install java $v"; rc=1
    else ok "java $v (Apple Silicon)"; fi
  done
  [ -z "$cur" ] || [ "$(sdk_current java)" = "$cur" ] || ! sdk_has java "$cur" || sdk_run default java "$cur" >/dev/null 2>&1
  return $rc
}
sdk_offered() { # CANDIDATE VERSION -> 0 offered, 1 no longer offered, 2 couldn't tell
  local r
  r="$(curl -fsS --max-time 20 "https://api.sdkman.io/2/candidates/validate/$1/$2/$(sdk_platform)" 2>/dev/null)"
  case "$r" in valid) return 0 ;; invalid) return 1 ;; *) return 2 ;; esac
}
newest_temurin() { # MAJOR -> newest Temurin build SDKMAN lists for this Mac
  curl -fsS --max-time 20 "https://api.sdkman.io/2/candidates/java/$(sdk_platform)/versions/list?current=&installed=" 2>/dev/null |
    awk -F'|' -v m="$1" '{ id = $NF; gsub(/[[:space:]]/, "", id); if (index(id, m ".") == 1 && id ~ /-tem$/) { print id; exit } }'
}
ensure_sdk() { # CANDIDATE VERSION; sets SDK_PICKED to what is (or would be) installed
  local cand="$1" ver="$2" offered=0
  SDK_PICKED=""
  if [ "$cand" = java ] && ! sdk_has java "$ver"; then
    sdk_offered java "$ver"
    offered=$?
  fi
  if [ "$offered" = 1 ]; then
    ver="$(newest_temurin "${2%%.*}")"
    [ -n "$ver" ] || { bad "java $2 is no longer offered by SDKMAN and no Temurin ${2%%.*} build was found"; return 1; }
    warn "java $2 is no longer offered by SDKMAN; using $ver (the onboarding docs need updating)"
  fi
  SDK_PICKED="$ver"
  if sdk_has "$cand" "$ver"; then ok "$cand $ver"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "sdk install $cand $ver"; return 0; fi
  say "Installing $cand $ver..."
  sdk_install "$cand" "$ver" || { bad "sdk install $cand $ver failed"; return 1; }
  sdk_has "$cand" "$ver" || { bad "$cand $ver is missing after sdk install"; return 1; }
  ok "$cand $ver"
}
sdk_config() { # KEY VALUE in ~/.sdkman/etc/config
  local f="$SDKMAN_DIR/etc/config"
  [ -f "$f" ] || { bad "$(tildify "$f") is missing"; return 1; }
  if grep -qx "$1=$2" "$f"; then ok "$1=$2"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "set $1=$2 in $(tildify "$f")"; return 0; fi
  if grep -q "^$1=" "$f"; then sed -i '' "s/^$1=.*/$1=$2/" "$f"; else printf '%s=%s\n' "$1" "$2" >>"$f"; fi
  ok "$1=$2"
}
step_sdkman() {
  local log="" f aside="" n=1
  if [ ! -s "$SDKMAN_DIR/bin/sdkman-init.sh" ]; then
    if [ -e "$SDKMAN_DIR" ] || [ -L "$SDKMAN_DIR" ]; then # left by an interrupted install: start clean rather than install over it
      aside="$SDKMAN_DIR.ngss-backup-$RUN_TS"
      while [ -e "$aside" ] || [ -L "$aside" ]; do aside="$SDKMAN_DIR.ngss-backup-$RUN_TS-$n"; n=$((n + 1)); done
      if [ "$DRY_RUN" = 1 ]; then
        dry "move the incomplete $(tildify "$SDKMAN_DIR") (no bin/sdkman-init.sh) aside, to $(tildify "$aside")"
      elif mv "$SDKMAN_DIR" "$aside"; then
        ok "moved the incomplete $(tildify "$SDKMAN_DIR") aside, to $(tildify "$aside")"
        note "It had no bin/sdkman-init.sh, perhaps from an interrupted install. Delete it once Java works."
      else
        bad "couldn't move the incomplete $(tildify "$SDKMAN_DIR") (no bin/sdkman-init.sh) aside; move or delete it, then run this again"
        return 1
      fi
    fi
    if [ "$DRY_RUN" = 1 ]; then dry 'curl -s "https://get.sdkman.io" | zsh'; return 0; fi
    say "Installing SDKMAN..."
    mkdir -p "$LOG_DIR"
    log="$LOG_DIR/sdkman-install-$RUN_TS.log"
    for f in "$ZDIR/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile"; do backup "$f"; done # its installer edits these
    # zsh, as on the Development Environment Setup page: SDKMAN's installer refuses Bash 3.2, the bash macOS has
    if ! curl -fsSL "https://get.sdkman.io" | zsh >"$log" 2>&1; then
      bad "SDKMAN didn't install (log: $(tildify "$log"))"
      tail -n 15 "$log" | sed 's/^/          /'
      return 1
    fi
    chain_bash_profile # SDKMAN's installer creates ~/.bash_profile on macOS
  fi
  if ! sdk_init; then
    bad "couldn't load SDKMAN from $(tildify "$SDKMAN_DIR")"
    [ -z "$log" ] || tail -n 15 "$log" | sed 's/^/          /'
    return 1
  fi
  ok "SDKMAN $(sdkman_version)"
  if sdk_platform_wrong; then
    warn "SDKMAN is set up for Intel Macs (darwinx64), so its JDKs are Intel builds that need Rosetta; Migration Assistant copies it that way"
    if ask_yn "    Switch SDKMAN to Apple Silicon builds and reinstall its JDKs: $(installed_jdks | paste -sd ' ' - | sed 's/ /, /g')?" Y; then fix_sdk_platform || true
    else note "Later: $ME doctor --fix"; fi
  fi
  sdk_config sdkman_auto_env true && note "The JDK now switches to whatever each repo's .sdkmanrc pins."
}
step_java() {
  local rc=0 def cur
  if ! sdk_init; then
    if [ "$DRY_RUN" = 1 ]; then dry "sdk install java $JAVA_EXTRA, java $JAVA_DEFAULT, maven $MAVEN_VERSION, gradle"; return 0; fi
    bad "SDKMAN isn't installed"
    return 1
  fi
  ensure_sdk java "$JAVA_EXTRA" || rc=1
  ensure_sdk java "$JAVA_DEFAULT" || rc=1
  def="$SDK_PICKED"
  cur="$(sdk_current java)"
  if [ -n "$def" ] && [ "$cur" != "$def" ] && [ "$SET_JAVA_DEFAULT" = 1 ] && { [ "$DRY_RUN" = 1 ] || sdk_has java "$def"; }; then # a failed install was reported already
    if [ "$DRY_RUN" = 1 ]; then dry "sdk default java $def"
    elif sdk_run default java "$def" >/dev/null 2>&1; then ok "default java: $def"
    else bad "couldn't make java $def the default"; rc=1; fi
  elif [ -n "$cur" ]; then
    ok "default java: $cur"
  fi
  ensure_sdk maven "$MAVEN_VERSION" || rc=1
  if [ -z "$(sdk_current maven)" ] && [ -n "$SDK_PICKED" ] && [ "$DRY_RUN" != 1 ]; then sdk_run default maven "$SDK_PICKED" >/dev/null 2>&1; fi
  if [ -n "$(ls "$SDKMAN_DIR/candidates/gradle" 2>/dev/null)" ]; then
    ok "gradle $(sdk_current gradle)"
  elif [ "$DRY_RUN" = 1 ]; then
    dry "sdk install gradle"
  else
    say "Installing gradle..."
    if sdk_install gradle >/dev/null 2>&1; then ok "gradle $(sdk_current gradle)"; else bad "sdk install gradle failed"; rc=1; fi
  fi
  return $rc
}

# --- Shell environment, credentials, Maven settings -----------------------------------------
step_shell() {
  local f rc=0
  case "${SHELL##*/}" in
    zsh | bash) ;;
    *) warn "your login shell is ${SHELL:-unknown}; these settings go in $(tildify "$ZDIR/.zshrc"), so copy them to your shell's startup file too"
      NOTE="login shell ${SHELL##*/}" rc=2 ;;
  esac
  while IFS= read -r f <&3; do write_block "$f" || rc=1; done 3<<EOF
$(rc_files)
EOF
  if [ -n "$(cred_summary)" ]; then
    write_credentials || return 1
  else
    warn "no credentials entered yet; run '$ME credentials' when you have them"
    NOTE="no credentials yet"
    [ "$rc" = 1 ] || rc=2
  fi
  disable_outside_creds
  case $? in 1) rc=1 ;; 2) [ "$rc" = 1 ] || rc=2 ;; esac
  maybe_delete_import_file
  note "Open a new terminal (or run: source $(tildify "$(rc_files | tail -n 1)")) to pick these up."
  return $rc
}

settings_missing() { # FILE -> required settings.xml pieces it lacks
  local f="$1" out="" id
  for id in dtr.mapsandbox.net ckm-releases ckm-snapshots bitbucket opensaml; do # among the <servers>, since repositories use the same ids
    awk -v id="<id>$id</id>" '/<servers>/ { s = 1 } s && index($0, id) { found = 1 } /<\/servers>/ { s = 0 } END { exit !found }' "$f" || out="$out server:$id"
  done
  grep -q '<activeProfile>va-mobile</activeProfile>' "$f" || out="$out activeProfile:va-mobile"
  grep -q 'nexus.mobilehealth.va.gov/content/repositories/ckm-releases' "$f" || out="$out repository:ckm-releases"
  printf '%s' "${out# }"
}
step_maven_settings() {
  local f="$HOME/.m2/settings.xml" missing
  if [ -f "$f" ] && settings_xml | cmp -s - "$f"; then ok "~/.m2/settings.xml matches the onboarding template"; return 0; fi
  if [ -f "$f" ]; then
    missing="$(settings_missing "$f")"
    if [ -z "$missing" ]; then ok "~/.m2/settings.xml has everything required (customized, so left alone)"; return 0; fi
    warn "~/.m2/settings.xml is missing: $missing"
    ask_yn "    Replace it with the onboarding template? (a backup is kept)" Y || { NOTE="missing $missing"; return 2; }
  fi
  if [ "$DRY_RUN" = 1 ]; then dry "write ~/.m2/settings.xml (template from the Maven Configuration page)"; return 0; fi
  write_settings_xml
}
write_settings_xml() { # back up the old one, then swap the new one in whole, so a full disk can't leave it empty
  local f="$HOME/.m2/settings.xml" tmp mode=644
  mkdir -p "$HOME/.m2" || { bad "couldn't create ~/.m2"; return 1; }
  if [ -f "$f" ]; then
    backup "$f" || { bad "couldn't back up ~/.m2/settings.xml (is the disk full?), so it was left as it was"; return 1; }
    mode="$(stat -f %Lp "$f" 2>/dev/null)" || mode=644
  fi
  tmp="$(mktemp "$HOME/.m2/.settings.xml.XXXXXX" 2>/dev/null)" || { bad "couldn't write in ~/.m2 (is the disk full?); left settings.xml as it was"; return 1; }
  if settings_xml >"$tmp" && chmod "$mode" "$tmp" && mv -f "$tmp" "$f"; then ok "wrote ~/.m2/settings.xml"; return 0; fi
  rm -f "$tmp"
  bad "couldn't write ~/.m2/settings.xml (is the disk full?); left it as it was"
  return 1
}

# --- Containers and Kubernetes ----------------------------------------------------------------
docker_up() { setup_path; run_limited "$DOCKER_TIMEOUT" docker info >/dev/null 2>&1; } # 124: the engine isn't answering
kube_ready() { kubectl --context "$1" get nodes --no-headers --request-timeout=5s 2>/dev/null | grep -q ' Ready'; }
current_context() { kubectl config current-context 2>/dev/null || true; }
is_local_context() { printf '%s' "$1" | grep -Eq "$LOCAL_CONTEXTS"; }

active_runtime() { # -> orbstack or docker-desktop, from the docker CLI's context; empty for anything else
  case "$(docker context show 2>/dev/null)" in orbstack) echo orbstack ;; desktop-linux) echo docker-desktop ;; esac
}
use_docker_context() { # CONTEXT LABEL: point the docker CLI at this runtime, so images are built where its Kubernetes runs them
  local cur
  cur="$(docker context show 2>/dev/null)"
  [ -n "$cur" ] && [ "$cur" != "$1" ] || return 0
  if docker context use "$1" >/dev/null 2>&1; then ok "docker now builds with $2 (context $1; was $cur)"
  else warn "docker still uses context $cur; images it builds won't be in $2's Kubernetes. Run: docker context use $1"; fi
}
kick_runtime() { # start the runtime early so it is up by the time it is needed
  [ "$DRY_RUN" != 1 ] || return 0
  docker_up && return 0
  case "$RUNTIME" in
    orbstack) open -ga OrbStack >/dev/null 2>&1 ;;
    docker-desktop) open -ga Docker >/dev/null 2>&1 ;;
  esac
  return 0
}
start_orbstack() {
  if [ "$DRY_RUN" = 1 ]; then dry "start OrbStack; orb config set k8s.enable true"; return 0; fi
  app_present orbstack || has orb || { bad "OrbStack isn't installed"; return 1; }
  if ! docker_up; then
    open -a OrbStack >/dev/null 2>&1
    say "If OrbStack's welcome window is open, finish it (signing in can wait)."
    wait_until 300 "OrbStack's Docker engine" docker_up || { bad "OrbStack's Docker engine didn't start"; return 1; }
  fi
  use_docker_context orbstack OrbStack
  ok "Docker engine running (OrbStack)"
  if [ "$(orb config get k8s.enable 2>/dev/null)" != true ]; then
    orb config set k8s.enable true >/dev/null 2>&1 || { bad "couldn't turn on Kubernetes; use OrbStack > Settings > Kubernetes"; return 1; }
    ok "turned on OrbStack's Kubernetes cluster"
    if ! wait_until 90 "Kubernetes" kube_ready orbstack; then
      say "Restarting OrbStack so the setting takes effect..."
      orb stop >/dev/null 2>&1
      orb start >/dev/null 2>&1 || open -a OrbStack >/dev/null 2>&1
    fi
  fi
  wait_until 240 "Kubernetes" kube_ready orbstack || { bad "Kubernetes isn't ready; check OrbStack > Settings > Kubernetes"; return 1; }
  ok "Kubernetes ready (context orbstack)"
}
start_docker_desktop() {
  if [ "$DRY_RUN" = 1 ]; then dry "start Docker Desktop and check its Kubernetes"; return 0; fi
  app_present docker-desktop || { bad "Docker Desktop isn't installed"; return 1; }
  if ! docker_up; then
    open -a Docker >/dev/null 2>&1
    say "If Docker Desktop asks you to accept its terms or sign in, do that now."
    wait_until 300 "Docker Desktop" docker_up || { bad "Docker Desktop didn't start"; return 1; }
  fi
  use_docker_context desktop-linux "Docker Desktop"
  ok "Docker engine running (Docker Desktop)"
  if ! kube_ready docker-desktop; then
    warn "turn on Kubernetes: Docker Desktop > Settings > Kubernetes > Enable Kubernetes > Apply"
    note "Also set Settings > Resources: CPU 8, memory 20 GB or more (32 GB on a 64 GB Mac), swap 4 GB, disk 128 GB or more."
    ask_yn "    Wait for Kubernetes now?" Y || { NOTE="Kubernetes isn't on yet"; return 2; }
    wait_until 600 "Kubernetes" kube_ready docker-desktop || { bad "Kubernetes isn't ready"; return 1; }
  fi
  ok "Kubernetes ready (context docker-desktop)"
}
use_context() { # CONTEXT: make it current, since skaffold deploys to the current context
  local want="$1" cur
  [ -n "$want" ] || return 0
  cur="$(current_context)"
  if [ "$cur" = "$want" ]; then ok "kubectl context: $want"; return 0; fi
  if [ -n "$cur" ] && ! is_local_context "$cur"; then
    warn "kubectl's current context '$cur' isn't a local cluster, and skaffold deploys to the current context"
    ask_yn "    Switch to '$want'?" N || return 1
  fi
  kubectl config use-context "$want" >/dev/null && ok "kubectl context: $want (was ${cur:-unset})"
}
docker_login_stdin() { printf '%s\n' "$DTR_PWD" | docker login "$DTR_HOST" -u "$DTR_USER" --password-stdin; } # printf is a builtin, so the token never reaches a command line
docker_login_dtr() {
  if [ -z "${DTR_USER:-}" ] || [ -z "${DTR_PWD:-}" ]; then warn "no Sandbox DTR credentials yet; skipped docker login"; return 2; fi
  if is_encrypted "$DTR_PWD"; then warn "DTR_PWD is Maven-encrypted, and docker needs the plain token, so docker login was skipped"; return 2; fi
  if [ "$DRY_RUN" = 1 ]; then dry "docker login $DTR_HOST -u <DTR_USER> --password-stdin"; return 0; fi
  docker_up || { warn "Docker isn't running; skipped docker login"; return 2; }
  run_limited "$NET_TIMEOUT" docker_login_stdin >/dev/null 2>&1
  case $? in
    0) ok "docker login $DTR_HOST" ;;
    124) bad "docker login $DTR_HOST didn't answer in ${NET_TIMEOUT}s; are you on the VPN?"; return 1 ;;
    *) bad "docker login $DTR_HOST failed; check DTR_USER and DTR_PWD"; return 1 ;;
  esac
  if run_limited "$NET_TIMEOUT" docker manifest inspect "$DTR_TEST_IMAGE" >/dev/null 2>&1; then ok "can pull images ($DTR_TEST_IMAGE)"; return 0; fi
  warn "couldn't read $DTR_TEST_IMAGE; try: docker pull $DTR_TEST_IMAGE"
  return 2
}
step_runtime() {
  local ctx="" rc=0 r
  case "$RUNTIME" in
    orbstack) start_orbstack || return 1; ctx=orbstack ;;
    docker-desktop) start_docker_desktop; rc=$?; [ "$rc" != 1 ] || return 1; ctx=docker-desktop ;;
    *) if docker_up; then ok "Docker engine running"; else bad "no container runtime is running"; return 1; fi
      ctx="$(current_context)" ;;
  esac
  [ "$DRY_RUN" != 1 ] || { docker_login_dtr; return 0; }
  if ! has kubectl; then
    say "Installing kubectl..."
    if ! { brew_install_formulae kubernetes-cli && has kubectl; }; then bad "kubectl is missing"; return 1; fi
  fi
  use_context "$ctx" || rc=2
  docker_login_dtr
  r=$?
  [ "$r" -le "$rc" ] || rc=$r
  return $rc
}

# --- Jira CLI and Jenkins CLI -----------------------------------------------------------------
step_jira() {
  local login board log old_ifs me
  if ! has jira; then
    if [ "$DRY_RUN" = 1 ]; then dry "jira init (project $JIRA_PROJECT, board Greenfield)"; return 0; fi
    bad "jira-cli isn't installed"
    return 1
  fi
  [ -n "${JIRA_API_TOKEN:-}" ] || { warn "no Jira token yet; run '$ME credentials' when you have one"; NOTE="no token yet"; return 2; }
  login="${JIRA_LOGIN:-}"
  [ -n "$login" ] || login="$(jira_login)"
  [ -n "$login" ] || login="${VA_BITBT_USER:-}"
  [ -n "$login" ] || { warn "no Jira username"; NOTE="no username"; return 2; }
  if [ -f "$JIRA_CONFIG" ] && [ "$(jira_login)" = "$login" ]; then
    ok "jira-cli is set up for $login"
  elif [ "$DRY_RUN" = 1 ]; then
    dry "jira init --installation local --server $JIRA_SERVER --login $login --auth-type bearer --project $JIRA_PROJECT --board Greenfield"
    return 0
  else
    mkdir -p "$LOG_DIR"
    log="$LOG_DIR/jira-init-$RUN_TS.log"
    old_ifs="$IFS"
    IFS='|'
    # shellcheck disable=SC2086 # split on |, keeping the trailing space in "Greenfield "
    set -- $JIRA_BOARDS
    IFS="$old_ifs"
    for board in "$@"; do
      run_limited "$NET_TIMEOUT" jira init --installation local --server "$JIRA_SERVER" --login "$login" --auth-type bearer \
        --project "$JIRA_PROJECT" --board "$board" --force </dev/null >"$log" 2>&1
      case $? in
        0) ok "jira init: $login, project $JIRA_PROJECT, board '$board'"
          [ "$board" != None ] || warn "no default board; pass --board to sprint commands"
          break ;;
        124) bad "jira init didn't finish in ${NET_TIMEOUT}s; are you on the VPN? (log: $(tildify "$log"))"; return 1 ;;
      esac
      grep -q 'board not found' "$log" || { bad "jira init failed: $(tail -n 1 "$log")"; return 1; }
    done
    [ -f "$JIRA_CONFIG" ] || { bad "jira init didn't write $(tildify "$JIRA_CONFIG")"; return 1; }
  fi
  if me="$(run_limited "$NET_TIMEOUT" jira me </dev/null 2>/dev/null)" && [ -n "$me" ]; then ok "jira me: $me"; return 0; fi
  warn "'jira me' failed; check JIRA_API_TOKEN"
  return 2
}

step_jenkins() {
  if [ -s "$JENKINS_JAR" ] && unzip -tqq "$JENKINS_JAR" >/dev/null 2>&1; then # versions before 1.2.1 could save a sign-in page
    ok "$(tildify "$JENKINS_JAR")"
  elif [ "$DRY_RUN" = 1 ]; then
    dry "download $JENKINS_URL/jnlpJars/jenkins-cli.jar to $(tildify "$JENKINS_JAR")"
  else
    mkdir -p "$(dirname "$JENKINS_JAR")"
    if curl -fsSL --max-time 120 -o "$JENKINS_JAR.part" "$JENKINS_URL/jnlpJars/jenkins-cli.jar" && unzip -tqq "$JENKINS_JAR.part" >/dev/null 2>&1 &&
      mv -f "$JENKINS_JAR.part" "$JENKINS_JAR"; then
      ok "downloaded $(tildify "$JENKINS_JAR")"
    else
      rm -f "$JENKINS_JAR.part"
      bad "couldn't download jenkins-cli.jar from $JENKINS_URL, or it wasn't a JAR (a sign-in page?)"
      return 1
    fi
  fi
  if [ -z "${JENKINS_API_USER:-}" ] || [ -z "${JENKINS_API_TOKEN:-}" ]; then
    warn "no Jenkins token yet; jcli needs JENKINS_API_USER and JENKINS_API_TOKEN ($ME credentials)"
    NOTE="no token yet"
    return 2
  fi
  ok "the jcli helper is in your shell profile; try 'jcli who-am-i' in a new terminal"
}

# --- Git hooks (Modular Git Hooks page) -------------------------------------------------------
old_push_scan() { # HOOKS-DIR: 0 if its pre-push secret scan is the old version, unchanged since this script wrote it
  local f="$1/pre-push.d/20-trufflehog-full-scan"
  [ -f "$f" ] && [ "$(shasum -a 256 <"$f" 2>/dev/null | cut -c1-64)" = "$OLD_PUSH_SCAN_SHA" ]
}
outdated_hooks() { # HOOKS-DIR -> each hook file there that is an earlier version this script wrote, unchanged since, one per line
  local rel sum
  while IFS='|' read -r rel sum; do
    [ -f "$1/$rel" ] || continue
    [ "$(shasum -a 256 <"$1/$rel" 2>/dev/null | cut -c1-64)" != "$sum" ] || printf '%s\n' "$rel"
  done <<EOF
$OLD_HOOKS
EOF
}
hook_function() { # RELATIVE-PATH -> the function that prints that hook file
  case "$1" in
    dispatch) echo hook_dispatch ;;
    pre-commit.d/10-merge-conflict-guard) echo hook_merge_conflict_guard ;;
    pre-commit.d/20-build-artifact-guard) echo hook_build_artifact_guard ;;
    pre-commit.d/30-trufflehog-staged-scan) echo hook_trufflehog_staged_scan ;;
    pre-push.d/10-protected-branch-guard) echo hook_protected_branch_guard ;;
    pre-push.d/20-trufflehog-full-scan) echo hook_trufflehog_full_scan ;;
  esac
}
write_hook() { # RELATIVE-PATH FUNCTION
  local p="$HOOKS_DIR/$1"
  if [ -e "$p" ]; then ok "$1 (kept yours)"; return 0; fi
  if "$2" >"$p" && chmod +x "$p"; then ok "$1"; return 0; fi
  rm -f "$p" # a partial file would count as yours next time
  bad "couldn't write $(tildify "$p")"
  return 1
}
link_hook() { # NAME: point ~/.git-hooks/NAME at the dispatcher; a hook that was there moves into NAME.d/, so it still runs
  local p="$HOOKS_DIR/$1" dest target
  [ "$(readlink "$p" 2>/dev/null)" != dispatch ] || return 0
  dest="$HOOKS_DIR/$1.d/90-your-$1"
  [ ! -e "$dest" ] && [ ! -L "$dest" ] || dest="$dest-$RUN_TS"
  if [ -L "$p" ]; then
    target="$(readlink "$p")"
    case "$target" in /*) ;; *) target="$HOOKS_DIR/$target" ;; esac
    ln -s "$target" "$dest" || { bad "couldn't link your $1 hook ($target) into $1.d/; left it as it was"; return 1; }
    say "Your $1 hook ($(tildify "$target")) still runs: the dispatcher runs it as $(tildify "$dest")."
  elif [ -f "$p" ]; then
    mv "$p" "$dest" || { bad "couldn't move your own $1 hook into $1.d/; left it as it was"; return 1; }
    if [ -x "$dest" ]; then say "Your own $1 hook still runs: the dispatcher runs it as $(tildify "$dest")."
    else say "Your own $1 hook is now $(tildify "$dest"). It wasn't executable, so it didn't run before and doesn't now; chmod +x it to turn it on."; fi
  fi
  ln -sf dispatch "$p" || { bad "couldn't point $(tildify "$p") at the dispatcher"; return 1; }
}
unlinked_hooks() { # HOOKS-DIR -> the PASS_HOOKS events with nothing there, which git then skips in every repository
  local n
  for n in $PASS_HOOKS; do [ -e "$1/$n" ] || [ -L "$1/$n" ] || printf '%s\n' "$n"; done
}
link_pass_hooks() { # [HOOKS-DIR]: link those events to the dispatcher; hooks of yours with those names stay as they are
  local d="${1:-$HOOKS_DIR}" n list
  list="$(unlinked_hooks "$d")"
  [ -n "$list" ] || return 0
  if [ "$DRY_RUN" = 1 ]; then dry "link $(printf '%s' "$list" | paste -sd ' ' -) to the dispatcher in $(tildify "$d")"; return 0; fi
  for n in $list; do ln -s dispatch "$d/$n" || { bad "couldn't link $(tildify "$d/$n") to the dispatcher"; return 1; }; done
  ok "each repository's own hooks run again for: $(printf '%s' "$list" | paste -sd ',' - | sed 's/,/, /g')"
}
step_githooks() {
  local cur old rel rc=0
  cur="$(git config --global --get core.hooksPath 2>/dev/null || true)"
  case "$cur" in
    "" | "$HOOKS_DIR" | "~/.git-hooks") ;;
    *) warn "core.hooksPath is already $cur; left your hooks alone"; NOTE="kept $cur"; return 2 ;;
  esac
  if [ "$DRY_RUN" = 1 ]; then dry "create ~/.git-hooks (dispatcher and five modules) and set core.hooksPath"; return 0; fi
  mkdir -p "$HOOKS_DIR/pre-commit.d" "$HOOKS_DIR/pre-push.d" || return 1
  if [ -f "$HOOKS_DIR/dispatch" ] && grep -qF "$OLD_DISPATCH_LINE" "$HOOKS_DIR/dispatch"; then
    backup "$HOOKS_DIR/dispatch"
    rm -f "$HOOKS_DIR/dispatch"
    say "Replacing your old dispatcher: it dropped the last line of input, so pushes to main weren't blocked."
  fi
  if old_push_scan "$HOOKS_DIR"; then
    backup "$HOOKS_DIR/pre-push.d/20-trufflehog-full-scan"
    rm -f "$HOOKS_DIR/pre-push.d/20-trufflehog-full-scan"
    say "Replacing your pre-push secret scan: the old one could scan nothing and still report no secrets."
  fi
  old="$(outdated_hooks "$HOOKS_DIR")"
  if [ -n "$old" ]; then
    while IFS= read -r rel; do
      if backup "$HOOKS_DIR/$rel"; then rm -f "$HOOKS_DIR/$rel"; else warn "couldn't back up $rel, so it wasn't updated"; rc=2; fi
    done <<EOF
$old
EOF
    say "Updating your git hooks ($(printf '%s' "$old" | paste -sd ',' - | sed 's/,/, /g')): the new ones also check renamed files, scan what is staged, and run each repository's own hooks. Backups are kept."
  fi
  write_hook dispatch hook_dispatch || rc=1
  link_hook pre-commit || rc=1
  link_hook pre-push || rc=1
  link_pass_hooks || rc=1
  write_hook pre-commit.d/10-merge-conflict-guard hook_merge_conflict_guard || rc=1
  write_hook pre-commit.d/20-build-artifact-guard hook_build_artifact_guard || rc=1
  write_hook pre-commit.d/30-trufflehog-staged-scan hook_trufflehog_staged_scan || rc=1
  write_hook pre-push.d/10-protected-branch-guard hook_protected_branch_guard || rc=1
  write_hook pre-push.d/20-trufflehog-full-scan hook_trufflehog_full_scan || rc=1
  if [ -n "$cur" ] || git config --global core.hooksPath "$HOOKS_DIR"; then ok "core.hooksPath = $(tildify "$HOOKS_DIR")"
  else bad "couldn't set git's core.hooksPath, so git won't run these hooks"; rc=1; fi
  if ! has trufflehog; then
    warn "trufflehog isn't installed, so the secret scans skip themselves"
    NOTE="no trufflehog"
    [ "$rc" = 1 ] || rc=2
  fi
  return $rc
}

# --- macOS text settings (Development Environment Setup > Adjust Settings) ------------------
set_default() { # DOMAIN KEY 0|1 LABEL
  local want=false
  [ "$3" = 0 ] || want=true
  if [ "$(defaults read "$1" "$2" 2>/dev/null)" = "$3" ]; then ok "$4"; return 0; fi
  if run defaults write "$1" "$2" -bool "$want"; then ok "$4"; else bad "couldn't change the setting: $4"; return 1; fi
}
step_macos() {
  local rc=0
  set_default NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled 0 "smart quotes off" || rc=1
  set_default NSGlobalDomain NSAutomaticDashSubstitutionEnabled 0 "smart dashes off" || rc=1
  set_default com.apple.finder AppleShowAllFiles 1 "Finder shows hidden files (after Finder restarts, or press Cmd-Shift-.)" || rc=1
  note "Apps pick up the text settings the next time they start."
  return $rc
}

# --- Repositories and the location-service build --------------------------------------------
sdkmanrc_pins() { # FILE -> "candidate version" for each pin in a .sdkmanrc, comments aside
  local k v
  [ -f "$1" ] || return 0
  while IFS='=' read -r k v || [ -n "$k" ]; do
    k="$(trim "$k")"
    v="$(trim "${v%%#*}")" # java=21.0.10-tem # a comment
    case "$k" in "" | "#"*) continue ;; esac
    if [ -n "$v" ]; then printf '%s %s\n' "$k" "$v"; fi
  done <"$1"
}
pin_lines() { # DIR -> one "candidate version" line per .sdkmanrc pin SDKMAN doesn't have yet
  local k v
  while read -r k v; do
    sdk_has "$k" "$v" || printf '%s %s\n' "$k" "$v"
  done < <(sdkmanrc_pins "$1/.sdkmanrc")
}
missing_pins() { pin_lines "$1" | paste -sd ' ' -; } # DIR -> e.g. "java 21.0.5-tem maven 3.9.9"

install_repo_pins() { # DIR...: install each JDK/Maven version the repos' .sdkmanrc files pin, once
  local d cand ver list rc=0
  sdk_init || return 0
  list="$(for d in "$@"; do pin_lines "$d"; done | sort -u)"
  [ -n "$list" ] || return 0
  while read -r cand ver; do
    [ -n "$cand" ] || continue
    if [ "$DRY_RUN" = 1 ]; then dry "sdk install $cand $ver (pinned in .sdkmanrc)"; continue; fi
    say "Installing $cand $ver, which a repo's .sdkmanrc pins..."
    sdk_install "$cand" "$ver" >/dev/null 2>&1
    if sdk_has "$cand" "$ver"; then ok "$cand $ver"; else warn "couldn't install $cand $ver; check that repo's .sdkmanrc"; rc=2; fi
  done <<EOF
$list
EOF
  return $rc
}

step_repos() {
  local name path why dest url rc=0 r dirs=""
  [ "$DRY_RUN" = 1 ] || mkdir -p "$WORKSPACE_DIR" || return 1
  while IFS='|' read -r name path why; do
    [ -n "$name" ] || continue
    dest="$WORKSPACE_DIR/$name"
    url="$CODEREPO/scm/$path.git"
    dirs="$dirs|$dest"
    if [ -d "$dest/.git" ]; then ok "$name (already cloned)"; continue; fi
    if [ -e "$dest" ]; then warn "$(tildify "$dest") exists but isn't a git repo; skipped"; rc=2; continue; fi
    if [ "$DRY_RUN" = 1 ]; then dry "git clone $url $(tildify "$dest")"; continue; fi
    if git_quiet clone --quiet "$url" "$dest" </dev/null; then ok "$name: $why"; else bad "couldn't clone $url; check the Bitbucket credentials"; rc=1; fi
  done <<EOF
$REPOS
EOF
  local old_ifs="$IFS"
  IFS='|'
  # shellcheck disable=SC2086 # split on |
  set -- ${dirs#|}
  IFS="$old_ifs"
  install_repo_pins "$@"
  r=$?
  [ "$r" -le "$rc" ] || rc=$r
  return $rc
}

step_build() {
  local dir="$WORKSPACE_DIR/location-service" log ctx pid rc start
  if [ "$DRY_RUN" = 1 ]; then dry "(cd $(tildify "$dir") && mvn clean verify -Pwith-skaffold)"; return 0; fi
  [ -d "$dir" ] || { warn "location-service isn't cloned; skipped"; return 2; }
  docker_up || { bad "Docker isn't running"; return 1; }
  ctx="$(current_context)"
  is_local_context "$ctx" || { bad "kubectl context is '${ctx:-unset}', not a local cluster; skaffold would deploy there"; return 1; }
  mkdir -p "$LOG_DIR"
  log="$LOG_DIR/location-service-build-$RUN_TS.log"
  say "mvn clean verify -Pwith-skaffold (usually 5 to 15 minutes; log: $(tildify "$log"))"
  start=$(date +%s)
  (
    cd "$dir" || exit 1
    if [ -f .sdkmanrc ] && sdk_init; then sdk_run env >/dev/null 2>&1; fi
    export SKAFFOLD_PUSH=false SKAFFOLD_RPC_PORT=50051 DTR_URL="$DTR_HOST"
    if [ -x ./mvnw ]; then exec ./mvnw -B clean verify -Pwith-skaffold; else exec mvn -B clean verify -Pwith-skaffold; fi
  ) >"$log" 2>&1 </dev/null &
  pid=$!
  BG_PID=$pid
  while kill -0 "$pid" 2>/dev/null; do
    [ ! -t 1 ] || printf '\r    %s  %s\033[K' "$(elapsed "$start")" "$(tail -n 1 "$log" 2>/dev/null | cut -c1-90)"
    sleep 3
  done
  wait "$pid"
  rc=$?
  BG_PID=""
  [ ! -t 1 ] || printf '\r\033[K'
  if [ "$rc" -eq 0 ]; then
    ok "location-service built and its integration tests passed ($(elapsed "$start"))"
    note "The 'profile \"with-skaffold\" could not be activated' warning at the end is expected."
    return 0
  fi
  bad "the build failed after $(elapsed "$start"); last lines:"
  tail -n 25 "$log" | sed 's/^/          /'
  note "If the cause isn't clear, share $(tildify "$log") with the onboarding team."
  return 1
}

# --- Team repositories from Backstage (Resources > Service Repositories) ----------------------
backstage_repos() { # SYSTEMS (comma-separated) -> "repo<TAB>clone URL<TAB>components" per repo, "!<TAB>component<TAB>why" if unusable
  local q="" s json
  for s in $(printf '%s' "$1" | tr ',' ' '); do q="$q&filter=kind=component,spec.system=$s"; done
  json="$(curl -fsS --max-time 60 "$BACKSTAGE_API?${q#&}&fields=metadata.name,metadata.annotations" 2>/dev/null)" || return 1
  printf '%s' "$json" | python3 -c '
import json, re, sys
pat = re.compile(r"^(?:url:)?https://coderepo\.mobilehealth\.va\.gov/(?:projects/([^/]+)/repos/([^/?#]+)|scm/([^/]+)/([^/?#]+?)(?:\.git)?(?=[/?#]|$))", re.I)
repos = {}
for e in json.load(sys.stdin):
    meta = e.get("metadata") or {}
    src = (meta.get("annotations") or {}).get("backstage.io/source-location", "")
    m = pat.match(src.strip())
    if not m:
        print("!\t%s\t%s" % (meta.get("name", "?"), src or "no source location"))
        continue
    project, repo = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4))
    url = "https://coderepo.mobilehealth.va.gov/scm/%s/%s.git" % (project.lower(), repo)
    repos.setdefault(url, (repo, []))[1].append(meta.get("name", "?"))
for url, (repo, names) in sorted(repos.items(), key=lambda kv: kv[1][0].lower()):
    print("%s\t%s\t%s" % (repo, url, ", ".join(sorted(names))))
'
}

list_systems() {
  local json
  section "Backstage systems"
  json="$(curl -fsS --max-time 60 "$BACKSTAGE_API?filter=kind=component&fields=spec.system" 2>/dev/null)" || { bad "couldn't reach Backstage"; return 1; }
  printf '%s' "$json" | python3 -c '
import collections, json, sys
counts = collections.Counter(((e.get("spec") or {}).get("system") or "").lower() for e in json.load(sys.stdin))
for name, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
    if name and name != "set_me":
        print("    %-22s %3d component%s" % (name, n, "" if n == 1 else "s"))
'
  say "Clone one with: $ME clone --system <name>   (several: --system ckm,ces)"
}

url_shown() { printf '%s' "$1" | sed -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)[^/@]*@#\1#'; } # URL without user:token@, for messages
norm_url() { # git URL -> host/project/repo in lower case, so HTTPS and SSH remotes of one repository match
  printf '%s' "$1" | sed -E 's#^[a-z+]+://##; s#^[^@/]+@##; s#:[0-9]+/#/#; s#^([^/:]+):#\1/#; s#/scm/#/#; s#/$##; s#\.git$##' | tr '[:upper:]' '[:lower:]'
}

step_team_repos() { # clone every repository behind the components in CLONE_SYSTEMS
  local rows tab repo url comps dest origin n=0 total=0 have=0 rc=0 err dirs="" r old_ifs
  tab="$(printf '\t')"
  has python3 || { bad "this needs python3, which comes with the Command Line Tools"; return 1; }
  rows="$(backstage_repos "$CLONE_SYSTEMS")" || { bad "couldn't read $CLONE_SYSTEMS from Backstage"; return 1; }
  while IFS="$tab" read -r repo url comps; do
    case "$repo" in "") ;; "!") warn "$url has no Coderepo source location in Backstage ($comps); skipped" ;;
      *) total=$((total + 1)); [ ! -e "$WORKSPACE_DIR/$repo" ] || have=$((have + 1)) ;; esac
  done <<EOF
$rows
EOF
  if [ "$total" -eq 0 ]; then warn "no components found for '$CLONE_SYSTEMS'; run '$ME clone' to list the systems"; return 2; fi
  say "$total repositories in $CLONE_SYSTEMS; $have already in $(tildify "$WORKSPACE_DIR")"
  if [ "$COMMAND" = clone ] && [ "$have" -lt "$total" ] && [ "$DRY_RUN" != 1 ] && ! ask_yn "    Clone the other $((total - have)) into $(tildify "$WORKSPACE_DIR")?" Y; then return 0; fi
  [ "$DRY_RUN" = 1 ] || mkdir -p "$WORKSPACE_DIR" || return 1
  err="$(mktemp)"
  while IFS="$tab" read -r repo url comps; do
    case "$repo" in "" | "!") continue ;; esac
    n=$((n + 1))
    dest="$WORKSPACE_DIR/$repo"
    if [ -d "$dest/.git" ]; then
      origin="$(git -C "$dest" remote get-url origin 2>/dev/null)"
      if [ "$(norm_url "$origin")" = "$(norm_url "$url")" ]; then ok "[$n/$total] $repo (already cloned)"; dirs="$dirs|$dest"
      else warn "[$n/$total] $(tildify "$dest") holds a different repository ($(url_shown "$origin")); skipped $url"; rc=2; fi
      continue
    fi
    if [ -e "$dest" ]; then warn "[$n/$total] $(tildify "$dest") exists but isn't a git repo; skipped"; rc=2; continue; fi
    if [ "$DRY_RUN" = 1 ]; then dry "[$n/$total] git clone $url $(tildify "$dest")"; continue; fi
    if git_quiet clone --quiet "$url" "$dest" </dev/null 2>"$err"; then
      ok "[$n/$total] $repo ($comps)"
      dirs="$dirs|$dest"
    elif grep -qiE 'authentication failed|could not read username|terminal prompts disabled|access denied' "$err"; then
      bad "[$n/$total] git couldn't log in to $CODEREPO_HOST; fix your Bitbucket credentials ($ME credentials), then run this again"
      rm -f "$err"
      return 1
    else
      bad "[$n/$total] couldn't clone $url: $(tail -n 1 "$err")"
      rc=1
    fi
  done <<EOF
$rows
EOF
  rm -f "$err"
  old_ifs="$IFS"
  IFS='|'
  # shellcheck disable=SC2086 # split on |
  set -- ${dirs#|}
  IFS="$old_ifs"
  install_repo_pins "$@"
  r=$?
  [ "$r" -le "$rc" ] || rc=$r
  return $rc
}

# --- IntelliJ IDEA (plugins, VA code style and inspections, Copilot host) ----------------------
idea_apps() { # every installed IntelliJ IDEA app, a Toolbox install in ~/Applications first and Community Edition last
  local d n old_ifs="$IFS"
  IFS=:
  # shellcheck disable=SC2086 # split on :
  set -- $APP_DIRS
  IFS="$old_ifs"
  for d in "$@"; do
    for n in "IntelliJ IDEA Ultimate.app" "IntelliJ IDEA.app"; do [ ! -x "$d/$n/Contents/MacOS/idea" ] || printf '%s\n' "$d/$n"; done
  done
  for d in "$@"; do [ ! -x "$d/IntelliJ IDEA CE.app/Contents/MacOS/idea" ] || printf '%s\n' "$d/IntelliJ IDEA CE.app"; done
}
idea_app() { local a; a="$(idea_apps | head -n 1)"; [ -n "$a" ] && printf '%s' "$a"; }
idea_info() { # APP -> IDEA_VERSION, IDEA_DATA (e.g. IntelliJIdea2026.2) and IDEA_CONFIG
  local f="$1/Contents/Resources/product-info.json"
  IDEA_DATA="$(json_value "$f" dataDirectoryName)"
  IDEA_VERSION="$(json_value "$f" version)"
  IDEA_CONFIG="$HOME/Library/Application Support/JetBrains/$IDEA_DATA"
  [ -n "$IDEA_DATA" ]
}
idea_running() { pgrep -f "$1/Contents/MacOS/idea" >/dev/null 2>&1; }
idea_stopped() { ! idea_running "$1"; }
plugin_label() {
  case "$1" in
    "PlantUML integration") echo "PlantUML Integration" ;;
    com.google.gct.core) echo "Google Cloud Code" ;;
    com.majera.intellij.codereview.bitbucket) echo "Bitbucket Integration Pro" ;;
    com.github.copilot) echo "GitHub Copilot" ;;
    *) echo "$1" ;;
  esac
}
idea_plugin_ids() { # CONFIG -> IDs of the plugins installed there, one per line
  local p j id
  for p in "$1/plugins"/*; do
    if [ -d "$p/lib" ]; then
      for j in "$p"/lib/*.jar; do
        id="$(unzip -p "$j" META-INF/plugin.xml 2>/dev/null | grep -m 1 -oE '<id>[^<]+</id>' | sed -E 's#</?id>##g')"
        if [ -n "$id" ]; then printf '%s\n' "$id"; break; fi
      done
    elif [ "${p%.jar}" != "$p" ]; then
      unzip -p "$p" META-INF/plugin.xml 2>/dev/null | grep -m 1 -oE '<id>[^<]+</id>' | sed -E 's#</?id>##g'
    fi
  done
}

find_ide_profiles() { # PROFILES_DIR = a folder with both files: your mobile-framework clone, a fresh one, or the site
  local d="$WORKSPACE_DIR/mobile-framework/$IDE_FILES_PATH" t
  PROFILES_DIR="" PROFILES_FROM="" PROFILES_TMP=""
  if [ -f "$d/$IDEA_CODE_STYLE.xml" ] && [ -f "$d/$IDEA_INSPECTIONS.xml" ]; then
    PROFILES_DIR="$d" PROFILES_FROM="$(tildify "$d")"
    return 0
  fi
  t="$(mktemp -d)"
  PROFILES_TMP="$t"
  if git_ready && git_quiet clone --quiet --depth 1 "$CODEREPO/scm/dhsss/mobile-framework.git" "$t/mobile-framework" </dev/null >/dev/null 2>&1 &&
    [ -f "$t/mobile-framework/$IDE_FILES_PATH/$IDEA_CODE_STYLE.xml" ] && [ -f "$t/mobile-framework/$IDE_FILES_PATH/$IDEA_INSPECTIONS.xml" ]; then
    PROFILES_DIR="$t/mobile-framework/$IDE_FILES_PATH" PROFILES_FROM="mobile-framework (main)"
    return 0
  fi
  mkdir -p "$t/site"
  if curl -fsSL --max-time 30 -o "$t/site/$IDEA_CODE_STYLE.xml" "$SITE_ATTACHMENTS/$IDEA_CODE_STYLE.xml" &&
    curl -fsSL --max-time 30 -o "$t/site/$IDEA_INSPECTIONS.xml" "$SITE_ATTACHMENTS/$IDEA_INSPECTIONS.xml" &&
    grep -q "<code_scheme name=\"$IDEA_CODE_STYLE\"" "$t/site/$IDEA_CODE_STYLE.xml" &&
    grep -q "value=\"$IDEA_INSPECTIONS\"" "$t/site/$IDEA_INSPECTIONS.xml"; then
    PROFILES_DIR="$t/site" PROFILES_FROM="the onboarding site"
    return 0
  fi
  return 1
}

import_ide_file() { # SOURCE DEST LABEL: copy it in if missing, or if the source changed since the last import
  local mark sum
  mark="$NGSS_DIR/intellij/$(basename "$2").sha256"
  sum="$(shasum -a 256 "$1" | cut -d ' ' -f 1)"
  if [ -f "$2" ] && [ "$(cat "$mark" 2>/dev/null)" = "$sum" ]; then ok "$3 is current"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "import $3 into $(tildify "$2")"; return 0; fi
  mkdir -p "$(dirname "$2")" "$(dirname "$mark")" || return 1
  backup "$2"
  cp "$1" "$2" && printf '%s\n' "$sum" >"$mark" && ok "imported $3"
}

xml_insert_after() { # FILE LINE TEXT: insert TEXT after the first line whose trimmed content is LINE
  local tmp
  tmp="$(mktemp)"
  NGSS_MATCH="$2" NGSS_TEXT="$3" awk '
    { print; t = $0; gsub(/^[ \t]+|[ \t]+$/, "", t) }
    !done && t == ENVIRON["NGSS_MATCH"] { print ENVIRON["NGSS_TEXT"]; done = 1 }' "$1" >"$tmp" && cat "$tmp" >"$1"
  rm -f "$tmp"
}

set_code_style_default() { # CONFIG
  local f="$1/options/code.style.schemes.xml"
  if grep -qs "name=\"CURRENT_SCHEME_NAME\" value=\"$IDEA_CODE_STYLE\"" "$f"; then ok "default code style: $IDEA_CODE_STYLE"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "make $IDEA_CODE_STYLE the default code style"; return 0; fi
  mkdir -p "$1/options" || return 1
  backup "$f"
  if grep -qs 'name="CURRENT_SCHEME_NAME"' "$f"; then
    sed -i '' "s/\(name=\"CURRENT_SCHEME_NAME\" value=\"\)[^\"]*\"/\1$IDEA_CODE_STYLE\"/" "$f"
  elif grep -qs '<component name="CodeStyleSchemeSettings">' "$f"; then
    xml_insert_after "$f" '<component name="CodeStyleSchemeSettings">' "    <option name=\"CURRENT_SCHEME_NAME\" value=\"$IDEA_CODE_STYLE\" />"
  elif [ ! -s "$f" ]; then
    printf '<application>\n  <component name="CodeStyleSchemeSettings">\n    <option name="CURRENT_SCHEME_NAME" value="%s" />\n  </component>\n</application>\n' "$IDEA_CODE_STYLE" >"$f"
  else
    info "couldn't set the default code style in $(tildify "$f"); choose $IDEA_CODE_STYLE in Settings > Editor > Code Style"
    return 0
  fi
  ok "default code style: $IDEA_CODE_STYLE"
}

set_inspections_default() { # CONFIG: projects opened from now on start with the VA inspections
  local f="$1/options/project.default.xml" block
  if grep -qs 'InspectionProjectProfileManager' "$f"; then
    if grep -qs "name=\"PROJECT_PROFILE\" value=\"$IDEA_INSPECTIONS\"" "$f"; then ok "new projects use the $IDEA_INSPECTIONS inspections"
    else info "new projects already default to another inspection profile; left as is"; fi
    return 0
  fi
  if [ "$DRY_RUN" = 1 ]; then dry "make $IDEA_INSPECTIONS the inspection profile for new projects"; return 0; fi
  block="      <component name=\"InspectionProjectProfileManager\">
        <settings>
          <option name=\"PROJECT_PROFILE\" value=\"$IDEA_INSPECTIONS\" />
          <option name=\"USE_PROJECT_PROFILE\" value=\"false\" />
          <version value=\"1.0\" />
        </settings>
      </component>"
  mkdir -p "$1/options" || return 1
  if [ ! -f "$f" ]; then
    printf '<application>\n  <component name="ProjectManager">\n    <defaultProject>\n%s\n    </defaultProject>\n  </component>\n</application>\n' "$block" >"$f"
  elif grep -q '<defaultProject>' "$f"; then
    backup "$f"
    xml_insert_after "$f" '<defaultProject>' "$block"
  else
    info "couldn't find the default project in $(tildify "$f"); pick $IDEA_INSPECTIONS in each project's settings"
    return 0
  fi
  ok "new projects use the $IDEA_INSPECTIONS inspections"
}

set_copilot_host() { # CONFIG: the copilot-setup page warns that github.com limits the models you get
  local f="$1/options/github-copilot.xml" cur opt='    <option name="authenticationProvider" value="va.ghe.com" />'
  cur="$(sed -n 's/.*name="authenticationProvider" value="\([^"]*\)".*/\1/p' "$f" 2>/dev/null | head -n 1)"
  case "$cur" in
    va.ghe.com) ok "GitHub Copilot signs in through va.ghe.com"; return 0 ;;
    ?*) warn "GitHub Copilot signs in through $cur; the VA setup uses va.ghe.com (Tools > GitHub Copilot > General)"; return 0 ;;
  esac
  if [ "$DRY_RUN" = 1 ]; then dry "point GitHub Copilot at va.ghe.com"; return 0; fi
  mkdir -p "$1/options" || return 1
  if [ ! -f "$f" ]; then
    printf '<application>\n  <component name="github-copilot">\n%s\n  </component>\n</application>\n' "$opt" >"$f"
  elif grep -q '<component name="github-copilot">' "$f"; then
    backup "$f"
    xml_insert_after "$f" '<component name="github-copilot">' "$opt"
  else
    info "couldn't set Copilot's sign-in host; choose va.ghe.com in Tools > GitHub Copilot > General"
    return 0
  fi
  ok "GitHub Copilot will sign in through va.ghe.com"
}

step_intellij() {
  local app id have missing="" labels props log rc=0 old_ifs old
  app="$(idea_app)" || { warn "IntelliJ IDEA isn't installed; skipped"; NOTE="not installed"; return 2; }
  idea_info "$app" || { bad "couldn't read $(tildify "$app")/Contents/Resources/product-info.json"; return 1; }
  ok "IntelliJ IDEA $IDEA_VERSION (settings: $(tildify "$IDEA_CONFIG"))"
  if [ ! -d "$IDEA_CONFIG" ]; then # not opened since it was installed or upgraded
    old="$(importable_settings "$IDEA_DATA" | tr '|' '\n' | grep . | head -n 1)"
    if [ -n "$old" ]; then # writing here first would stop IntelliJ offering to import them
      warn "IntelliJ IDEA $IDEA_VERSION hasn't been opened yet, and can still import your settings from $old"
      note "Open it once and let it import them, quit it, then run: $ME intellij"
      NOTE="open IntelliJ once first"
      return 2
    fi
  fi
  if idea_running "$app"; then
    if [ "$DRY_RUN" = 1 ]; then
      dry "IntelliJ IDEA is open; it has to be closed first"
    elif [ "$ASSUME_YES" = 1 ] || ! ask_yn "    IntelliJ IDEA is open and would overwrite these settings when it quits. Quit it now, then continue?" Y; then
      warn "IntelliJ IDEA is open; quit it, then run: $ME intellij"; NOTE="IntelliJ was open"; return 2
    elif ! wait_until 180 "IntelliJ IDEA to quit" idea_stopped "$app"; then
      warn "IntelliJ IDEA is still open; quit it, then run: $ME intellij"; NOTE="IntelliJ was open"; return 2
    fi
  fi
  have="$(idea_plugin_ids "$IDEA_CONFIG")"
  old_ifs="$IFS"
  IFS='|'
  # shellcheck disable=SC2086 # split on |; the PlantUML plugin's ID has a space in it
  set -- $IDEA_PLUGINS
  IFS="$old_ifs"
  for id in "$@"; do
    if printf '%s\n' "$have" | grep -qxF -- "$id"; then ok "plugin: $(plugin_label "$id")"; else missing="$missing|$id"; fi
  done
  if [ -n "$missing" ]; then
    old_ifs="$IFS"
    IFS='|'
    # shellcheck disable=SC2086
    set -- ${missing#|}
    IFS="$old_ifs"
    labels="$(for id in "$@"; do plugin_label "$id"; done | paste -sd ',' - | sed 's/,/, /g')"
    if [ "$DRY_RUN" = 1 ]; then
      dry "idea installPlugins: $labels"
    else
      say "Installing plugins: $labels (this can take a few minutes)"
      mkdir -p "$LOG_DIR"
      log="$LOG_DIR/intellij-plugins-$RUN_TS.log"
      props="$(mktemp)"
      printf 'idea.config.path=%s\nidea.plugins.path=%s/plugins\nidea.system.path=%s/Library/Caches/JetBrains/%s\nidea.log.path=%s/Library/Logs/JetBrains/%s\n' \
        "$IDEA_CONFIG" "$IDEA_CONFIG" "$HOME" "$IDEA_DATA" "$HOME" "$IDEA_DATA" >"$props"
      IDEA_PROPERTIES="$props" "$app/Contents/MacOS/idea" installPlugins "$@" </dev/null >"$log" 2>&1
      rm -f "$props"
      have="$(idea_plugin_ids "$IDEA_CONFIG")"
      for id in "$@"; do
        if printf '%s\n' "$have" | grep -qxF -- "$id"; then ok "plugin: $(plugin_label "$id") (installed)"
        else bad "plugin: $(plugin_label "$id") didn't install (log: $(tildify "$log"))"; rc=1; fi
      done
    fi
  fi
  if find_ide_profiles; then
    say "Code style and inspections from $PROFILES_FROM"
    [ "$PROFILES_FROM" != "the onboarding site" ] || warn "the onboarding site's copies can lag behind mobile-framework"
    import_ide_file "$PROFILES_DIR/$IDEA_CODE_STYLE.xml" "$IDEA_CONFIG/codestyles/$IDEA_CODE_STYLE.xml" "code style $IDEA_CODE_STYLE" || rc=1
    import_ide_file "$PROFILES_DIR/$IDEA_INSPECTIONS.xml" "$IDEA_CONFIG/inspection/$IDEA_INSPECTIONS.xml" "inspections $IDEA_INSPECTIONS" || rc=1
    set_code_style_default "$IDEA_CONFIG" || rc=1
    set_inspections_default "$IDEA_CONFIG" || rc=1
  else
    warn "couldn't get the code style and inspection files from mobile-framework or the onboarding site"
    [ "$rc" -ne 0 ] || rc=2
  fi
  [ -z "$PROFILES_TMP" ] || rm -rf "$PROFILES_TMP"
  set_copilot_host "$IDEA_CONFIG" || rc=1
  return $rc
}

# --- Tidy: reclaim disk space ------------------------------------------------------------------------
SNAPSHOT_DAYS=30
TIDY_FREED=0
TIDY_FAILED=0
T_ICS_LIST="" T_ICS_KB=0 T_ICC_LIST="" T_ICC_KB=0 T_IC_VERS=""
T_DOCKER_KB=0 T_DOCKER_DESC=""
T_SDK_LIST="" T_SDK_KB=0 T_SDK_REPOS=0
T_SNAP_LIST="" T_SNAP_KB=0 T_SNAP_N=0 T_SNAP_LOCAL=0
T_BREW_KB=0

kb_of_lines() { # paths on stdin, one per line -> their total size in KB
  local p k t=0
  while IFS= read -r p; do
    [ -n "$p" ] || continue
    k="$(du -sk "$p" 2>/dev/null | awk '{ print $1 + 0 }')"
    t=$((t + ${k:-0}))
  done
  printf '%s' "$t"
}
human_kb() { awk -v k="${1:-0}" 'BEGIN { if (k >= 1048576) printf "%.1f GB", k / 1048576; else if (k >= 1024) printf "%.0f MB", k / 1024; else printf "%d KB", k }'; }
unit_kb() { # "9.6GB", "914.4MB", "12kB", "0B" -> KB
  printf '%s\n' "$1" | awk '{ v = $0 + 0; u = toupper($0); gsub(/[^A-Z]/, "", u)
    if (u ~ /^T/) v *= 1073741824; else if (u ~ /^G/) v *= 1048576; else if (u ~ /^M/) v *= 1024; else if (u !~ /^K/) v /= 1024
    printf "%d\n", v }'
}

jetbrains_data_dirs() { # dataDirectoryName of every installed JetBrains IDE, wherever and however it's named
  local f d old_ifs="$IFS"
  IFS=:
  # shellcheck disable=SC2086 # split on :
  set -- $APP_DIRS
  IFS="$old_ifs"
  for d in "$@"; do
    for f in "$d"/*.app/Contents/Resources/product-info.json "$d"/*/*.app/Contents/Resources/product-info.json; do
      [ ! -f "$f" ] || json_value "$f" dataDirectoryName
    done
  done
  for f in "$HOME/Library/Application Support/JetBrains/Toolbox/apps"/*/ch-*/*/*.app/Contents/Resources/product-info.json; do
    [ ! -f "$f" ] || json_value "$f" dataDirectoryName
  done
}
newest_older_settings() { # DATA-DIR (e.g. IntelliJIdea2026.2) -> the newest older settings folder of that product, if any
  local prefix ver d base v best="" bestv=""
  prefix="${1%%[0-9]*}" ver="${1#"${1%%[0-9]*}"}"
  for d in "$HOME/Library/Application Support/JetBrains/$prefix"*; do
    base="${d##*/}"
    [ -d "$d" ] || continue
    [[ "$base" =~ ^(IntelliJIdea|IdeaIC)[0-9]{4}\.[0-9]+$ ]] || continue
    v="${base#"$prefix"}"
    [ "$v" != "$ver" ] || continue
    [ "$(printf '%s\n%s\n' "$v" "$ver" | sort -t. -k1,1n -k2,2n | head -n 1)" = "$v" ] || continue # older
    if [ -z "$bestv" ] || [ "$(printf '%s\n%s\n' "$v" "$bestv" | sort -t. -k1,1n -k2,2n | tail -n 1)" = "$v" ]; then best="$base" bestv="$v"; fi
  done
  printf '%s' "$best"
}
ide_used_lately() { # DATA-DIR: 0 if that IntelliJ wrote logs or caches in the last $SNAPSHOT_DAYS days, so it's still in use
  local d                 # (installed somewhere tidy doesn't look, such as a custom Toolbox folder or another disk)
  for d in "$HOME/Library/Logs/JetBrains/$1" "$HOME/Library/Caches/JetBrains/$1"; do
    [ ! -d "$d" ] || ! find "$d" -type f -mtime -"$SNAPSHOT_DAYS" 2>/dev/null | grep -q . || return 0
  done
  return 1
}
importable_settings() { # DATA-DIR -> the older settings folders an IntelliJ that hasn't run yet can import, |-separated
  case "$1" in
    IdeaIC*) printf '%s|' "$(newest_older_settings "$1")" ;;
    # the unified IntelliJ IDEA (2025.3 and later) can also import Community Edition settings
    IntelliJIdea*) printf '%s|%s|' "$(newest_older_settings "$1")" "$(newest_older_settings "IdeaIC${1#IntelliJIdea}")" ;;
  esac
}
tidy_scan_intellij() { # settings (T_ICS_*), and caches and logs (T_ICC_*), of IntelliJ versions that are no longer installed
  local keep="|" keepcfg="|" a d p base cfg="$HOME/Library/Application Support/JetBrains"
  T_ICS_LIST="" T_ICS_KB=0 T_ICC_LIST="" T_ICC_KB=0 T_IC_VERS=""
  while IFS= read -r a; do [ -z "$a" ] || keep="$keep$a|"; done <<EOF
$(jetbrains_data_dirs)
EOF
  case "$keep" in *"|IntelliJIdea"* | *"|IdeaIC"*) ;; *) return 0 ;; esac # no IntelliJ found, so no way to tell what's current
  while IFS= read -r a; do # an IntelliJ not run since an upgrade still has to import the newest older settings
    [ -d "$cfg/$a" ] || keepcfg="$keepcfg$(importable_settings "$a")"
  done <<EOF
$(jetbrains_data_dirs)
EOF
  for d in "$cfg" "$HOME/Library/Caches/JetBrains" "$HOME/Library/Logs/JetBrains"; do
    for p in "$d"/IntelliJIdea* "$d"/IdeaIC*; do
      [ -d "$p" ] || continue
      base="${p##*/}"
      [[ "$base" =~ ^(IntelliJIdea|IdeaIC)[0-9]{4}\.[0-9]+$ ]] || continue # not copies such as IntelliJIdea2026.2.bak
      case "$keep" in *"|$base|"*) continue ;; esac
      ide_used_lately "$base" && continue
      if [ "$d" = "$cfg" ]; then
        case "$keepcfg" in *"|$base|"*) continue ;; esac
        T_ICS_LIST="$T_ICS_LIST$p$NL"
      else
        T_ICC_LIST="$T_ICC_LIST$p$NL"
      fi
    done
  done
  [ -n "$T_ICS_LIST$T_ICC_LIST" ] || return 0
  [ -z "$T_ICS_LIST" ] || T_ICS_KB="$(printf '%s' "$T_ICS_LIST" | kb_of_lines)"
  [ -z "$T_ICC_LIST" ] || T_ICC_KB="$(printf '%s' "$T_ICC_LIST" | kb_of_lines)"
  T_IC_VERS="$(printf '%s' "$T_ICS_LIST$T_ICC_LIST" | sed -E 's#.*/(IntelliJIdea|IdeaIC)##' | sort -t. -k1,1n -k2,2n -u | paste -sd ',' - | sed 's/,/, /g')"
}

tidy_scan_docker() { # unused images and build cache, as `docker system df` counts them
  # (not its volumes: it counts every unused volume, but `docker volume prune` removes only anonymous ones)
  local type rec k
  T_DOCKER_KB=0 T_DOCKER_DESC=""
  has docker && docker_up || return 0
  while IFS='|' read -r type rec; do
    rec="${rec%% *}"
    case "$type" in Images | "Build Cache") ;; *) continue ;; esac
    k="$(unit_kb "$rec")"
    [ "${k:-0}" -gt 0 ] || continue
    T_DOCKER_KB=$((T_DOCKER_KB + k))
    T_DOCKER_DESC="$T_DOCKER_DESC, $(printf '%s' "$type" | tr '[:upper:]' '[:lower:]') $rec"
  done <<EOF
$(docker system df --format '{{.Type}}|{{.Reclaimable}}' 2>/dev/null)
EOF
  T_DOCKER_DESC="${T_DOCKER_DESC#, }"
}

tidy_scan_sdk() { # JDK, Maven and Gradle versions that no repository in the workspace pins
  local keep f k v cand dir base size
  T_SDK_LIST="" T_SDK_KB=0 T_SDK_REPOS=0
  [ -d "$SDKMAN_DIR/candidates" ] || return 0
  keep=" java/$JAVA_DEFAULT java/$JAVA_EXTRA maven/$MAVEN_VERSION "
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    T_SDK_REPOS=$((T_SDK_REPOS + 1))
    while read -r k v; do keep="$keep$k/$v "; done < <(sdkmanrc_pins "$f")
  done <<EOF
$(find -L "$WORKSPACE_DIR" -maxdepth 3 -name .sdkmanrc -type f 2>/dev/null)
EOF
  [ "$T_SDK_REPOS" -gt 0 ] || return 0 # no repos to say which versions are needed, so don't guess
  # JDKs that IntelliJ projects use (its SDK table), or that JAVA_HOME or a shell profile names
  while IFS= read -r v; do [ -z "$v" ] || keep="${keep}java/$v "; done <<EOF
$({ cat "$HOME/Library/Application Support/JetBrains"/*/options/jdk.table.xml 2>/dev/null; printf '%s\n' "${JAVA_HOME:-}"; profile_files | while IFS= read -r f; do [ -z "$f" ] || cat "$f"; done; } |
  grep -o 'candidates/java/[^/"$ :]*' | sed 's#candidates/java/##' | grep -vx current | sort -u)
EOF
  for cand in java maven gradle; do
    keep="$keep$cand/$(sdk_current "$cand") "
    for dir in "$SDKMAN_DIR/candidates/$cand"/*; do
      if [ ! -d "$dir" ] || [ -L "$dir" ]; then continue; fi
      base="${dir##*/}"
      case "$keep" in *" $cand/$base "*) continue ;; esac
      size="$(du -sk "$dir" 2>/dev/null | awk '{ print $1 + 0 }')"
      T_SDK_LIST="$T_SDK_LIST$cand $base$NL"
      T_SDK_KB=$((T_SDK_KB + ${size:-0}))
    done
  done
}

local_only_snapshot() { # DIR: 0 if `mvn install` put it there and no repository has it, so Maven couldn't download it again
  [ -f "$1/maven-metadata-local.xml" ] || return 1
  ! find "$1" -maxdepth 1 -name 'maven-metadata-*.xml' ! -name maven-metadata-local.xml 2>/dev/null | grep -q .
}
tidy_scan_snapshots() { # Maven SNAPSHOT folders nothing has touched for a month, except ones built only on this Mac
  local d
  T_SNAP_LIST="" T_SNAP_KB=0 T_SNAP_N=0 T_SNAP_LOCAL=0
  [ -d "$HOME/.m2/repository" ] || return 0
  while IFS= read -r d; do
    [ -n "$d" ] || continue
    ! find "$d" -type f -mtime -"$SNAPSHOT_DAYS" 2>/dev/null | grep -q . || continue # a build used it lately: Maven rewrites its metadata
    if local_only_snapshot "$d"; then T_SNAP_LOCAL=$((T_SNAP_LOCAL + 1)); else T_SNAP_LIST="$T_SNAP_LIST$d$NL"; fi
  done <<EOF
$(find "$HOME/.m2/repository" -type d -name '*-SNAPSHOT' -prune -print 2>/dev/null)
EOF
  T_SNAP_LIST="${T_SNAP_LIST%"$NL"}"
  [ -n "$T_SNAP_LIST" ] || return 0
  T_SNAP_N="$(printf '%s\n' "$T_SNAP_LIST" | grep -c .)"
  T_SNAP_KB="$(printf '%s\n' "$T_SNAP_LIST" | kb_of_lines)"
}

tidy_scan_brew() { # what `brew cleanup -s` would free
  local human
  T_BREW_KB=0
  has brew || return 0
  human="$(brew cleanup -n -s 2>/dev/null | sed -n 's/.*would free approximately \(.*\) of disk space.*/\1/p' | tail -n 1)"
  [ -z "$human" ] || T_BREW_KB="$(unit_kb "$human")"
}

tidy_remove_intellij_caches() {
  local p rc=0
  while IFS= read -r p; do
    case "$p" in "$HOME/Library/Caches/JetBrains/"* | "$HOME/Library/Logs/JetBrains/"*) rm -rf -- "$p" 2>/dev/null || { warn "couldn't delete $(tildify "$p")"; rc=1; } ;; esac
  done <<EOF
$T_ICC_LIST
EOF
  return $rc
}
tidy_remove_intellij_settings() { # to the Trash, not deleted: settings can't be downloaded again
  local p dest rc=0
  mkdir -p "$HOME/.Trash" 2>/dev/null
  while IFS= read -r p; do
    case "$p" in "$HOME/Library/Application Support/JetBrains/"*) ;; *) continue ;; esac
    dest="$HOME/.Trash/${p##*/} (tidy $RUN_TS)"
    mv -- "$p" "$dest" 2>/dev/null || { warn "couldn't move $(tildify "$p") to the Trash; left it where it is"; rc=1; }
  done <<EOF
$T_ICS_LIST
EOF
  [ "$rc" = 1 ] || note "They're in the Trash, if you need anything back."
  return $rc
}
docker_major() { docker version --format '{{.Client.Version}}' 2>/dev/null | head -n 1 | sed 's/^\([0-9]*\).*/\1/'; }
tidy_remove_docker() { # only what nothing has used for $SNAPSHOT_DAYS days, so images you built lately stay
  local m
  docker image prune -a -f --filter "until=$((SNAPSHOT_DAYS * 24))h" >/dev/null && docker builder prune -a -f --filter "until=$((SNAPSHOT_DAYS * 24))h" >/dev/null || return 1
  m="$(docker_major)"
  # before Docker 23, volume prune also removed named volumes, such as a database's data
  if [ -n "$m" ] && [ "$m" -ge 23 ]; then docker volume prune -f >/dev/null; else info "left Docker volumes alone: this Docker CLI's volume prune would remove named volumes too"; fi
}
tidy_remove_sdk() {
  local cand ver rc=0
  sdk_init || return 1
  while read -r cand ver; do
    [ -n "$cand" ] || continue
    sdk_run uninstall "$cand" "$ver" </dev/null >/dev/null 2>&1 || rm -rf -- "${SDKMAN_DIR:?}/candidates/$cand/$ver" 2>/dev/null
    [ ! -d "$SDKMAN_DIR/candidates/$cand/$ver" ] || { warn "couldn't uninstall $cand $ver"; rc=1; }
  done <<EOF
$T_SDK_LIST
EOF
  return $rc
}
tidy_remove_snapshots() {
  local p rc=0
  while IFS= read -r p; do
    case "$p" in "$HOME/.m2/repository/"*-SNAPSHOT) rm -rf -- "$p" 2>/dev/null || { warn "couldn't delete $(tildify "$p")"; rc=1; } ;; esac
  done <<EOF
$T_SNAP_LIST
EOF
  return $rc
}
tidy_remove_brew() { brew cleanup -s >/dev/null 2>&1; }

tidy_line() { [ "${1:-0}" -le 0 ] || printf '    %9s  %s\n' "$(human_kb "$1")" "$2"; }
tidy_offer() { # KB QUESTION FUNCTION Y|N: Y only for what comes back by itself, so --yes never removes anything else
  [ "${1:-0}" -gt 0 ] || return 0
  ask_yn "    $2 ($(human_kb "$1"))?" "$4" || { [ "$ASSUME_YES" != 1 ] || skip "$2: not done with --yes; run tidy without it to choose"; return 0; }
  if "$3"; then ok "freed about $(human_kb "$1")"; TIDY_FREED=$((TIDY_FREED + $1)); else warn "some of it couldn't be removed"; TIDY_FAILED=1; fi
}

cmd_tidy() {
  local total sdk_names
  banner "reclaim disk space"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes or --dry-run."; fi
  setup_path
  section "What tidy can reclaim"
  tidy_scan_intellij
  tidy_scan_docker
  tidy_scan_sdk
  tidy_scan_snapshots
  tidy_scan_brew
  sdk_names="$(printf '%s' "$T_SDK_LIST" | paste -sd ',' - | sed 's/,/, /g')"
  [ -z "$T_IC_VERS" ] || note "IntelliJ IDEA versions that aren't installed and haven't run for $SNAPSHOT_DAYS days: $T_IC_VERS"
  tidy_line "$T_ICC_KB" "their caches and logs"
  tidy_line "$T_ICS_KB" "their settings"
  tidy_line "$T_DOCKER_KB" "unused Docker images and build cache ($T_DOCKER_DESC; less if some were used lately), plus unused anonymous volumes"
  tidy_line "$T_SDK_KB" "SDKMAN versions that no repository in $(tildify "$WORKSPACE_DIR"), IntelliJ project or JAVA_HOME uses: $sdk_names"
  tidy_line "$T_SNAP_KB" "$T_SNAP_N Maven SNAPSHOT folders no build has used for $SNAPSHOT_DAYS days or more"
  [ "$T_SNAP_LOCAL" = 0 ] || note "Kept $T_SNAP_LOCAL old SNAPSHOT folders that were built on this Mac (mvn install) and never deployed: Maven can't download those again."
  tidy_line "$T_BREW_KB" "Homebrew downloads and old versions"
  [ "$T_SDK_REPOS" -gt 0 ] || info "skipped SDKMAN versions: no repositories in $(tildify "$WORKSPACE_DIR") to show which are pinned (use --workspace)"
  total=$((T_ICC_KB + T_ICS_KB + T_DOCKER_KB + T_SDK_KB + T_SNAP_KB + T_BREW_KB))
  if [ "$total" -eq 0 ]; then ok "nothing to tidy"; return 0; fi
  printf '    %9s  %s\n' "$(human_kb "$total")" "in total"
  if [ "$DRY_RUN" = 1 ]; then note "Dry run: nothing removed. Run without --dry-run to choose what goes."; return 0; fi
  section "Removing"
  note "Yes is the default only for what comes back by itself; the rest can't simply be downloaded again."
  tidy_offer "$T_ICC_KB" "Delete the old IntelliJ IDEA caches and logs (IntelliJ rebuilds them)" tidy_remove_intellij_caches Y
  tidy_offer "$T_ICS_KB" "Move the old IntelliJ IDEA settings to the Trash" tidy_remove_intellij_settings N
  tidy_offer "$T_DOCKER_KB" "Remove Docker images and build cache unused for $SNAPSHOT_DAYS days, and unused anonymous volumes (pulled or rebuilt when next needed)" tidy_remove_docker Y
  tidy_offer "$T_SDK_KB" "Uninstall $sdk_names (SDKMAN may no longer offer older versions)" tidy_remove_sdk N
  tidy_offer "$T_SNAP_KB" "Delete the old SNAPSHOT folders (Maven downloads them again, on the VPN, unless Nexus has removed them)" tidy_remove_snapshots N
  tidy_offer "$T_BREW_KB" "Run brew cleanup" tidy_remove_brew Y
  [ "$TIDY_FREED" -eq 0 ] || say "Freed about $(human_kb "$TIDY_FREED")."
  [ "$TIDY_FAILED" = 0 ]
}

# --- Forget: remove saved credentials --------------------------------------------------------------
history_files() {
  local f
  for f in "$HOME/.zsh_history" "$HOME/.bash_history" "$HOME"/.zsh_sessions/*.history; do [ ! -f "$f" ] || printf '%s\n' "$f"; done
}
cred_backups() { # profile backups this script made that still hold credential lines
  local f
  for f in "$HOME"/.zshenv.ngss-backup-* "$HOME"/.zprofile.ngss-backup-* "$HOME"/.zshrc.ngss-backup-* "$HOME"/.zlogin.ngss-backup-* \
    "$HOME"/.bash_profile.ngss-backup-* "$HOME"/.bashrc.ngss-backup-* "$HOME"/.profile.ngss-backup-* \
    "$ZDIR"/.zshenv.ngss-backup-* "$ZDIR"/.zprofile.ngss-backup-* "$ZDIR"/.zshrc.ngss-backup-* "$ZDIR"/.zlogin.ngss-backup-*; do
    if [ -f "$f" ] && grep -qE "$(assign_regex "$CRED_VARS")" "$f"; then printf '%s\n' "$f"; fi
  done
}

maven_cred_backups() { # ~/.m2/settings.xml, and backups of it made when it was replaced, that have passwords written into them
  local f
  for f in "$HOME"/.m2/settings.xml "$HOME"/.m2/settings.xml.ngss-backup-*; do
    [ -f "$f" ] || continue
    # a <password> or <passphrase> with a value, other than a ${...} reference such as the onboarding template's
    if awk '{ s = $0; while (match(s, /<(password|passphrase)>[^<]*</)) { v = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
        sub(/^<[a-z]*>/, "", v); sub(/<$/, "", v); gsub(/^[[:space:]]+|[[:space:]]+$/, "", v)
        if (v != "" && v !~ /^\$\{[^}]*\}$/) { found = 1; exit } } } END { exit !found }' "$f"; then printf '%s\n' "$f"; fi
  done
}
tildify_lines() { local f out=""; while IFS= read -r f; do [ -z "$f" ] || out="$out, $(tildify "$f")"; done; printf '%s' "${out#, }"; } # paths, one per line -> "~/a, ~/b"

cmd_forget() {
  local items=0 saved="" defs backups hist="" mvn f n left v kinds="" bu ju failed=0
  banner "remove saved credentials"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes or --dry-run."; fi
  setup_path
  for v in VA_BITBT_PWD VA_NEXUS_PWD DTR_PWD JIRA_API_TOKEN JENKINS_API_TOKEN; do # for the revoke pointers at the end
    if [ -n "$(file_value "$v")" ] || [ -n "${!v:-}" ]; then kinds="$kinds $v"; fi
  done
  bu="$(file_value VA_BITBT_USER)" ju="$(file_value JENKINS_API_USER)"
  [ -n "$bu" ] || bu="${VA_BITBT_USER:-}"
  [ -n "$ju" ] || ju="${JENKINS_API_USER:-}"
  section "Saved credentials"
  if [ -f "$CRED_FILE" ]; then say "$(tildify "$CRED_FILE")"; items=$((items + 1)); fi
  if git_ready; then saved="$(coderepo_saved)"; fi
  if [ -n "$saved" ]; then
    say "git's saved $CODEREPO_HOST login (user $(cred_field "$saved" username))"
    items=$((items + 1))
    case "$kinds " in *" VA_BITBT_PWD "*) ;; *) kinds="$kinds VA_BITBT_PWD" ;; esac
    [ -n "$bu" ] || bu="$(cred_field "$saved" username)"
  fi
  if grep -qsF "\"$DTR_HOST\"" "$HOME/.docker/config.json"; then
    say "docker login for $DTR_HOST"
    items=$((items + 1))
    case "$kinds " in *" DTR_PWD "*) ;; *) kinds="$kinds DTR_PWD" ;; esac
  fi
  if [ -f "$JIRA_CONFIG" ]; then say "Jira CLI settings ($(jira_login))"; items=$((items + 1)); fi
  defs="$(outside_defs "$CRED_VARS")"
  if [ -n "$defs" ]; then say "credential lines in $defs"; items=$((items + 1)); fi
  backups="$(cred_backups)"
  if [ -n "$backups" ]; then say "profile backups that still hold credentials: $(printf '%s\n' "$backups" | while IFS= read -r f; do tildify "$f"; printf ' '; done)"; items=$((items + 1)); fi
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    n="$(grep -acE "(VA_BITBT_PWD|VA_NEXUS_PWD|DTR_PWD|JIRA_API_TOKEN|JENKINS_API_TOKEN)=" "$f" 2>/dev/null)"
    [ "${n:-0}" -eq 0 ] || hist="$hist $(tildify "$f") ($n)"
  done <<EOF
$(history_files)
EOF
  [ -z "$hist" ] || warn "shell history sets token variables in:$hist. This script doesn't edit history; remove those lines yourself."
  mvn="$(maven_cred_backups)"
  [ -z "$mvn" ] || warn "Maven settings with passwords written into them: $(printf '%s\n' "$mvn" | tildify_lines). This script doesn't change them; replace each password with a \${env.VAR} reference (Maven Configuration page), or delete the backups, yourself."
  if [ "$items" -eq 0 ]; then
    if [ -n "$hist$mvn" ]; then ok "nothing for forget to remove"; else ok "no saved credentials found"; fi
    return 0
  fi
  if [ "$DRY_RUN" = 1 ]; then note "Dry run: nothing removed."; return 0; fi
  if [ "$ASSUME_YES" != 1 ] && ! ask_yn "    Remove all of these?" N; then return 0; fi
  section "Removing"
  if [ -f "$CRED_FILE" ]; then
    if rm -f "$CRED_FILE"; then ok "deleted $(tildify "$CRED_FILE")"; else bad "couldn't delete $(tildify "$CRED_FILE")"; failed=1; fi
  fi
  if [ -n "$saved" ]; then
    if git_forget_coderepo; then ok "removed git's saved $CODEREPO_HOST login"; else bad "git still has a saved $CODEREPO_HOST login"; failed=1; fi
  fi
  if grep -qsF "\"$DTR_HOST\"" "$HOME/.docker/config.json"; then
    if docker logout "$DTR_HOST" >/dev/null 2>&1; then ok "docker logout $DTR_HOST"
    else bad "docker logout $DTR_HOST failed, so docker still has the token (is OrbStack or Docker Desktop installed?)"; failed=1; fi
  fi
  if [ -f "$JIRA_CONFIG" ]; then
    if rm -f "$JIRA_CONFIG"; then ok "deleted the Jira CLI settings"; else bad "couldn't delete $(tildify "$JIRA_CONFIG")"; failed=1; fi
  fi
  if [ -n "$defs" ]; then disable_outside_creds forget; fi
  backups="$(cred_backups)" # includes the one disable_outside_creds just made
  if [ -n "$backups" ]; then
    printf '%s\n' "$backups" | while IFS= read -r f; do rm -f "$f"; done
    backups="$(cred_backups)"
    if [ -z "$backups" ]; then ok "deleted profile backups that held credentials"
    else bad "couldn't delete these profile backups, which hold credentials: $(printf '%s\n' "$backups" | while IFS= read -r f; do tildify "$f"; printf ' '; done)"; failed=1; fi
  fi
  left="$(outside_defs "$CRED_VARS")"
  if [ -n "$left" ]; then bad "credential lines are still in $left; remove them by hand"; fi
  section "Next"
  say "Close and reopen your terminals. This one still has the old values in memory."
  [ -z "$mvn" ] || say "Check the Maven settings listed above, and remove the passwords yourself; forget leaves them alone."
  say "IntelliJ and its Bitbucket plugin keep their own copy of the token; remove it in IntelliJ's Git and Bitbucket Integration Pro settings."
  say "To set up credentials again: $ME credentials"
  revoke_hints "$kinds" "$bu" "$ju"
  [ -z "$left" ] && [ "$failed" = 0 ]
}

revoke_hints() { # "VARS" BITBUCKET_USER JENKINS_USER: where to revoke the tokens forget removed copies of
  local kinds=" $1 " onb=0
  # Only the Bitbucket username shows whose token it is: Nexus and DTR usernames are random User Token names
  if [ -n "$2" ] && printf '%s' "$2" | grep -Eq "^($ONBOARDING_USERS_REGEX)"; then
    case "$kinds" in *" VA_BITBT_PWD "*) onb=1
      say "The Bitbucket token was the onboarding team's. If it may have leaked, tell the onboarding team so they can replace it." ;;
    esac
    kinds="${kinds/ VA_BITBT_PWD / }"
  fi
  case "$kinds" in *[A-Z]*) ;; *) return 0 ;; esac
  say "Deleting copies doesn't stop a token from working. If one may have leaked, revoke it where you created it:"
  [ "$onb" = 0 ] || case "$kinds" in *" VA_NEXUS_PWD "* | *" DTR_PWD "*) note "(VA Nexus and Sandbox DTR tokens that were still the onboarding team's: tell the onboarding team instead)" ;; esac
  case "$kinds" in *" VA_BITBT_PWD "*) note "Bitbucket: $CODEREPO > your account > Manage account > HTTP access tokens" ;; esac
  case "$kinds" in *" VA_NEXUS_PWD "*) note "VA Nexus: https://nexus.mobilehealth.va.gov > your account > User Token > Reset User Token" ;; esac
  case "$kinds" in *" DTR_PWD "*) note "Sandbox DTR: https://nexus.mapsandbox.net > your account > User Token > Reset User Token" ;; esac
  case "$kinds" in *" JIRA_API_TOKEN "*) note "Jira: $JIRA_SERVER/secure/ViewProfile.jspa?selectedTab=com.atlassian.pats.pats-plugin:jira-user-personal-access-tokens" ;; esac
  case "$kinds" in *" JENKINS_API_TOKEN "*) note "Jenkins: $JENKINS_URL/user/${3:-<your user>}/security/" ;; esac
}

# --- Update: replace this script with the published version ----------------------------------------
cmd_update() {
  local self link tmp published
  banner "update this script"
  self="${BASH_SOURCE[0]:-}"
  if [ -z "$self" ] || [ ! -f "$self" ]; then die "This copy isn't a file (it was piped into bash). Download it again: curl -fsSL $SCRIPT_URL -o $ME"; fi
  self="$(cd "$(dirname "$self")" && pwd)/$(basename "$self")"
  while [ -L "$self" ]; do # replace the file a symlink points to, not the symlink
    link="$(readlink "$self")"
    case "$link" in /*) self="$link" ;; *) self="$(dirname "$self")/$link" ;; esac
  done
  if tracked_by_git "$self"; then die "$(tildify "$self") is part of a git repository; update it with git instead."; fi
  if [ ! -w "$self" ] || [ ! -w "$(dirname "$self")" ]; then die "Can't write $(tildify "$self"), or the folder it's in."; fi
  tmp="$(mktemp)" || die "Couldn't make a temporary file (is the disk full?)"
  if ! curl -fsSL --max-time 60 -o "$tmp" "$SCRIPT_URL"; then rm -f "$tmp"; die "Couldn't download $SCRIPT_URL"; fi
  # A download cut short can still parse, so also require the script's last line.
  if ! grep -q '^ME="ngss-dev-setup.sh"$' "$tmp" || [ "$(tail -n 1 "$tmp")" != "main \"\$@\"" ] || ! /bin/bash -n "$tmp" 2>/dev/null; then
    rm -f "$tmp"
    die "The download isn't a complete copy of $ME; nothing changed."
  fi
  published="$(sed -n 's/^VERSION="\(.*\)"$/\1/p' "$tmp" | head -n 1)"
  if [ "$published" = "$VERSION" ]; then
    ok "$ME $VERSION is the latest"
  elif ! version_newer "$published" "$VERSION"; then
    ok "this copy ($VERSION) is newer than the published one ($published); left as is"
  elif [ "$DRY_RUN" = 1 ]; then
    dry "replace $(tildify "$self") ($VERSION) with $published"
  else
    # Write a new file and rename it into place: bash keeps reading the old file while this runs.
    if ! { cp -p "$self" "$self.previous" && cp "$tmp" "$self.new" && chmod "$(stat -f %Lp "$self")" "$self.new" && mv -f "$self.new" "$self"; }; then
      rm -f "$tmp" "$self.new" 2>/dev/null
      die "Couldn't replace $(tildify "$self"); it's unchanged."
    fi
    ok "updated $(tildify "$self") from $VERSION to $published (the old copy is $(tildify "$self.previous"))"
  fi
  rm -f "$tmp"
}
tracked_by_git() { # FILE -> 0 if a git repository tracks it, so git, not update, should change it
  local d="${1%/*}"
  if git_ready; then git -C "$d" ls-files --error-unmatch -- "${1##*/}" >/dev/null 2>&1; return; fi
  while :; do # no git to ask: a .git in its folder or one above it, short of HOME, where a dotfiles repository is common
    [ "$d" != "$HOME" ] || return 1
    [ ! -e "$d/.git" ] || return 0
    case "$d" in / | "" | .) return 1 ;; esac
    d="$(dirname "$d")"
  done
}
version_newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -t. -k1,1n -k2,2n -k3,3n | tail -n 1)" = "$1" ]; } # A B -> 0 if version A is newer

# --- Upgrade: newer versions of the tools this script installs --------------------------------------
# SDKMAN provides the Java and Maven builds use, at the versions each repo pins, so Homebrew's are left alone.
JAVA_PKGS_RE='^(maven|gradle|openjdk(@[0-9]+)?|java[0-9]*|temurin(@[0-9]+)?|zulu(@[0-9]+)?|corretto(@[0-9]+)?|liberica.*|sapmachine.*|microsoft-openjdk.*|oracle-jdk.*|graalvm.*)$'
in_list() { case " $2 " in *" $1 "*) return 0 ;; esac; return 1; } # WORD "LIST"
commas() { printf '%s' "$*" | sed 's/ /, /g'; }                     # WORDS -> "a, b, c"
major_of() { printf '%s' "${1%%[!0-9]*}"; }                         # VERSION -> its leading number
ngss_formulas() { local SELECTED="$RECOMMENDED $EXTRAS"; printf '%s graphviz kubernetes-cli' "$(plan_formulas)"; }
ngss_casks() { local SELECTED="$RECOMMENDED $EXTRAS" RUNTIME=""; printf 'orbstack docker-desktop %s' "$(plan_casks)"; }
cask_label() { local a; a="$(cask_apps "$1" | cut -d'|' -f1)"; a="${a%.app}"; printf '%s' "${a:-$1}"; }
cask_running() { # CASK -> 0 if one of its apps is running; Homebrew quits an app to upgrade it
  local app old_ifs="$IFS" names
  names="$(cask_apps "$1")"
  IFS='|'
  for app in $names; do
    if pgrep -f "/$app/Contents/MacOS/" >/dev/null 2>&1; then IFS="$old_ifs"; return 0; fi
  done
  IFS="$old_ifs"
  return 1
}
brew_intel() { [ "$(brew --prefix 2>/dev/null)" = "$BREW_X86" ]; } # Intel Homebrew, which no longer gets prebuilt packages
brew_run() { HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_ENV_HINTS=1 brew "$@"; }
not_brews() { # FORMULAS... -> "command (path)" for each whose command on PATH isn't Homebrew's copy
  local f c p prefix out=""
  prefix="$(brew --prefix 2>/dev/null)"
  for f in "$@"; do
    c="$(formula_cmd "$f")"
    p="$(command -v "$c" 2>/dev/null)" || continue
    case "$p" in "$prefix"/Cellar/* | "$prefix"/opt/*) continue ;; esac
    case "$(readlink "$p" 2>/dev/null)" in *Cellar/*) continue ;; esac # Homebrew links bin/<cmd> to ../Cellar/...
    out="$out, $c ($(tildify "$p"))"
  done
  printf '%s' "${out#, }"
}

brew_outdated() { # one line per outdated package: f|c, name, full name, installed, newest, pinned (1|0)
  brew_run outdated --json=v2 2>/dev/null | python3 -c '
import json, sys
d = json.load(sys.stdin)
for kind, key in (("f", "formulae"), ("c", "casks")):
    for e in d.get(key) or []:
        full = e.get("name") or "?"
        have = (e.get("installed_versions") or ["?"])[-1]
        print("\t".join((kind, full.rsplit("/", 1)[-1], full, str(have), str(e.get("current_version") or "?"), "1" if e.get("pinned") else "0")))
' 2>/dev/null
}
still_outdated() { # NAMES... -> the ones `brew outdated` still lists
  local now n left=""
  now="$(brew_outdated | cut -f2 | tr '\n' ' ')"
  for n in "$@"; do
    if in_list "$n" "$now"; then left="$left $n"; fi
  done
  printf '%s' "${left# }"
}
brew_scan() { # sort `brew outdated` by what upgrade does with each package, into the UP_* variables
  local out kind short full have want pinned fl cl
  UP_F="" UP_FN="" UP_C="" UP_CN="" UP_LIST="" UP_HELD="" UP_HELD_N="" UP_RUNNING="" UP_SELF="" UP_PINNED="" UP_JAVA=""
  UP_OTHER="" UP_OTHER_F="" UP_OTHER_C=""
  out="$(brew_outdated)" || return 1
  fl="$(ngss_formulas)"
  cl="$(ngss_casks)"
  while IFS="$TAB" read -r kind short full have want pinned; do
    [ -n "$short" ] || continue
    have="${have%%,*}" want="${want%%,*}" # casks: "2.2.4,21120" -> "2.2.4"
    if [[ "$short" =~ $JAVA_PKGS_RE ]]; then
      UP_JAVA="$UP_JAVA $short"
    elif { [ "$kind" = f ] && ! in_list "$short" "$fl"; } || { [ "$kind" = c ] && ! in_list "$short" "$cl"; }; then
      if [ "$pinned" != 1 ]; then
        UP_OTHER="$UP_OTHER $short"
        if [ "$kind" = f ]; then UP_OTHER_F="$UP_OTHER_F $full"; else UP_OTHER_C="$UP_OTHER_C $full"; fi
      fi
    elif [ "$pinned" = 1 ]; then
      UP_PINNED="$UP_PINNED $short"
    elif [ "$kind" = c ] && in_list "$short" "$SELF_UPDATING_CASKS"; then
      UP_SELF="$UP_SELF, $(cask_label "$short") $want"
    elif in_list "$short" "$HOLD_MAJOR" && [ "$(major_of "$have")" != "$(major_of "$want")" ]; then
      UP_HELD="$UP_HELD, $short $want" UP_HELD_N="$UP_HELD_N $full"
    elif [ "$kind" = c ] && cask_running "$short"; then
      UP_RUNNING="$UP_RUNNING, $(cask_label "$short")"
    else
      if [ "$kind" = f ]; then UP_F="$UP_F $full" UP_FN="$UP_FN $short"; else UP_C="$UP_C $full" UP_CN="$UP_CN $short"; fi
      UP_LIST="$UP_LIST$(printf '      %-16s %s -> %s' "$short" "$have" "$want")$NL"
    fi
  done <<EOF
$out
EOF
  UP_SELF="${UP_SELF#, }" UP_HELD="${UP_HELD#, }" UP_RUNNING="${UP_RUNNING#, }"
}

step_upgrade_self() { # 2 when a newer copy of this script is published
  local w="$N_WARN"
  check_latest verbose
  [ "$N_WARN" -eq "$w" ] || { NOTE="run: $ME update"; return 2; }
}

# shellcheck disable=SC2086 # the UP_* lists are meant to split into words
step_upgrade_brew() {
  local rc=0 log intel=0 do_f=0 do_c=0 done_n="" left skipped="" n show notes="" ofail shadow
  if ! has brew; then warn "Homebrew isn't installed; run: $ME install"; return 2; fi
  clt_ready || { warn "Homebrew needs the Xcode Command Line Tools, which are missing: xcode-select --install"; return 2; }
  ! brew_intel || intel=1
  if [ "$intel" = 1 ] && arm_mac; then
    warn "the only Homebrew here is an Intel copy in $(tildify "$BREW_X86"), which can't install or upgrade anything on Apple Silicon"
    note "Install Homebrew for Apple Silicon with '$ME install'; it sets up $(tildify "$BREW_ARM") and puts it first in new terminals."
    return 2
  fi
  if [ "$DRY_RUN" = 1 ]; then
    dry "brew update"
  else
    mkdir -p "$LOG_DIR"
    log="$LOG_DIR/brew-update-$RUN_TS.log"
    say "Updating Homebrew..."
    if brew_run update >"$log" 2>&1; then
      ok "$(brew --version 2>/dev/null | head -n 1)"
    else
      warn "brew update failed, so this list may be out of date (log: $(tildify "$log"))"
      rc=2 notes="$notes, brew update failed"
    fi
  fi
  brew_scan || { bad "couldn't read Homebrew's list of outdated packages (brew outdated --json=v2)"; return 1; }
  if [ -n "$UP_LIST" ]; then say "NGSS tools with upgrades:"; printf '%s' "$UP_LIST"; else ok "the NGSS tools are up to date"; fi
  shadow="$(not_brews $UP_FN)"
  [ -z "$shadow" ] || info "on your PATH these aren't Homebrew's, so upgrading Homebrew's copies won't change what you run: $shadow"
  if [ -n "$UP_HELD" ]; then
    warn "held back, a new major version: $UP_HELD. Builds, deployments or the git hooks may need changes first; check with the team, then run: brew upgrade$UP_HELD_N"
    rc=2 notes="$notes, new major version held back"
  fi
  if [ -n "$UP_RUNNING" ]; then
    warn "$UP_RUNNING is running, and Homebrew closes an app to upgrade it; quit it and run '$ME upgrade' again"
    rc=2 notes="$notes, $UP_RUNNING running"
  fi
  [ -z "$UP_SELF" ] || info "apps that update themselves, so use the app's own update when it suits you: $UP_SELF"
  [ -z "$UP_PINNED" ] || info "pinned with brew pin, so left alone: $(commas $UP_PINNED)"
  [ -z "$UP_JAVA" ] || info "not upgraded here: $(commas $UP_JAVA) (SDKMAN manages the Java and Maven you build with)"
  if [ -n "$UP_F$UP_C" ]; then
    if [ "$intel" = 1 ] && [ -n "$UP_F" ]; then
      warn "Homebrew stopped making prebuilt packages for Intel Macs in September 2026, so newer versions of $(commas $UP_FN) build from source; that can take hours and can fail"
    fi
    if [ "$DRY_RUN" = 1 ]; then
      if [ -n "$UP_F" ] && [ "$intel" = 1 ]; then dry "brew upgrade --formula$UP_F (only if you say yes; on an Intel Mac the default is no)"
      elif [ -n "$UP_F" ]; then dry "brew upgrade --formula$UP_F"; fi
      [ -z "$UP_C" ] || dry "brew upgrade --cask$UP_C"
    elif [ "$intel" = 1 ] && [ -n "$UP_F" ]; then
      if [ -n "$UP_C" ] && ask_yn "    Upgrade $(commas $UP_CN)?" Y; then do_c=1; fi
      if ask_yn "    Build $(commas $UP_FN) from source anyway?" N; then do_f=1; else rc=2 notes="$notes, Intel Mac: tools not built from source"; fi
    elif ask_yn "    Upgrade them?" Y; then
      do_f=1 do_c=1
    fi
    if [ "$do_f" = 1 ] && [ -n "$UP_F" ]; then brew_run upgrade --formula $UP_F; done_n="$done_n$UP_FN"; else skipped="$skipped$UP_FN"; fi
    if [ "$do_c" = 1 ] && [ -n "$UP_C" ]; then brew_run upgrade --cask $UP_C; done_n="$done_n$UP_CN"; else skipped="$skipped$UP_CN"; fi
    if [ -n "$done_n" ]; then
      left="$(still_outdated $done_n)"
      if [ -z "$left" ]; then ok "upgraded $(commas $done_n)"; else bad "$(commas $left) didn't upgrade; see Homebrew's output above"; rc=1; fi
    fi
    [ "$DRY_RUN" = 1 ] || [ -z "$skipped" ] || skip "not upgraded: $(commas $skipped)"
  fi
  if [ -n "$UP_OTHER" ]; then
    n="$(printf '%s\n' $UP_OTHER | grep -c .)"
    show="$(printf '%s\n' $UP_OTHER | head -n 6 | paste -sd ' ' -)"
    show="$(commas $show)"
    [ "$n" -le 6 ] || show="$show and $((n - 6)) more"
    info "$n other Homebrew packages have upgrades: $show"
    if [ "$DRY_RUN" != 1 ] && [ "$ASSUME_YES" != 1 ]; then
      [ "$intel" != 1 ] || note "On this Intel Mac, most of them would build from source."
      if ask_yn "    Upgrade those too? They aren't part of the NGSS setup" N; then
        ofail=0
        [ -z "$UP_OTHER_F" ] || brew_run upgrade --formula $UP_OTHER_F || ofail=1
        [ -z "$UP_OTHER_C" ] || brew_run upgrade --cask $UP_OTHER_C || ofail=1
        if [ "$ofail" = 0 ]; then ok "upgraded the other packages"; else warn "some of the other packages didn't upgrade; see Homebrew's output above"; [ "$rc" = 1 ] || rc=2; fi
      fi
    fi
  fi
  NOTE="${notes#, }"
  return $rc
}

sdkman_published() { # script|native -> the SDKMAN version published on this Mac's channel
  local channel=stable
  ! grep -qx 'sdkman_beta_channel=true' "$SDKMAN_DIR/etc/config" 2>/dev/null || channel=beta
  curl -fsS --max-time 20 "https://api.sdkman.io/2/broker/version/sdkman/$1/$channel" 2>/dev/null | tr -d '[:space:]'
}
step_upgrade_sdkman() {
  local rc=0 r f d n=0 dirs="" missing have have_n want want_n log old_ifs
  sdk_init || { warn "SDKMAN isn't installed; run: $ME install"; return 2; }
  info "Java and Maven aren't upgraded: each repository's .sdkmanrc pins the versions it builds with"
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    dirs="$dirs|${f%/.sdkmanrc}"
    n=$((n + 1))
  done <<EOF
$(find -L "$WORKSPACE_DIR" -maxdepth 3 -name .sdkmanrc -type f 2>/dev/null)
EOF
  if [ "$n" -eq 0 ]; then
    skip "no repositories in $(tildify "$WORKSPACE_DIR") to check for newly pinned versions (use --workspace)"
  else
    old_ifs="$IFS"
    IFS='|'
    # shellcheck disable=SC2086 # split on |
    set -- ${dirs#|}
    IFS="$old_ifs"
    missing="$(for d in "$@"; do pin_lines "$d"; done)"
    if [ -z "$missing" ]; then
      ok "every version the $n repositories in $(tildify "$WORKSPACE_DIR") pin is installed"
    else
      install_repo_pins "$@" # before selfupdate, which changes SDKMAN under this shell
      r=$?
      [ "$r" -le "$rc" ] || rc=$r
    fi
  fi
  have="$(cat "$SDKMAN_DIR/var/version" 2>/dev/null)"
  have_n="$(cat "$SDKMAN_DIR/var/version_native" 2>/dev/null)"
  want="$(sdkman_published script)"
  want_n="$(sdkman_published native)"
  if [ -z "$want" ]; then
    skip "couldn't check for a newer SDKMAN"
  elif [ "$want" = "$have" ] && { [ -z "$want_n" ] || [ "$want_n" = "$have_n" ]; }; then
    ok "SDKMAN $have is the latest"
  elif grep -qx 'sdkman_selfupdate_feature=false' "$SDKMAN_DIR/etc/config" 2>/dev/null; then
    warn "SDKMAN $want is available, but sdkman_selfupdate_feature=false in ~/.sdkman/etc/config turns off sdk selfupdate"
    rc=2
  elif [ "$DRY_RUN" = 1 ]; then
    if [ "$want_n" = "$have_n" ]; then dry "sdk selfupdate (SDKMAN $have -> $want)"
    else dry "sdk selfupdate (SDKMAN $have, native $have_n -> $want, native $want_n)"; fi
  else
    mkdir -p "$LOG_DIR"
    log="$LOG_DIR/sdkman-selfupdate-$RUN_TS.log"
    say "Updating SDKMAN..."
    # shellcheck disable=SC2034 # read by SDKMAN; its config file wins when it sets this
    sdkman_selfupdate_feature=true
    sdk_run selfupdate </dev/null >"$log" 2>&1
    if [ "$(cat "$SDKMAN_DIR/var/version" 2>/dev/null)" = "$want" ] &&
      { [ -z "$want_n" ] || [ "$(cat "$SDKMAN_DIR/var/version_native" 2>/dev/null)" = "$want_n" ]; }; then
      if [ "$have" != "$want" ]; then ok "updated SDKMAN from $have to $want"; else ok "updated SDKMAN's native part from $have_n to $want_n"; fi
    else
      bad "sdk selfupdate didn't finish (log: $(tildify "$log"))"
      rc=1
    fi
  fi
  return $rc
}

step_upgrade_jenkins() { # keep the CLI JAR the same as the one the Sandbox Jenkins serves
  local tmp
  tmp="$(mktemp)" || return 1
  if ! curl -fsSL --connect-timeout 15 --max-time 120 -o "$tmp" "$JENKINS_URL/jnlpJars/jenkins-cli.jar" || [ ! -s "$tmp" ]; then
    rm -f "$tmp"
    warn "couldn't download jenkins-cli.jar from $JENKINS_URL to compare"
    return 2
  fi
  if ! unzip -tqq "$tmp" >/dev/null 2>&1; then
    rm -f "$tmp"
    warn "what $JENKINS_URL sent isn't a JAR (a sign-in page?), so $(tildify "$JENKINS_JAR") was kept"
    return 2
  fi
  if cmp -s "$tmp" "$JENKINS_JAR"; then rm -f "$tmp"; ok "$(tildify "$JENKINS_JAR") matches $JENKINS_URL"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then rm -f "$tmp"; dry "replace $(tildify "$JENKINS_JAR") with the one $JENKINS_URL serves now"; return 0; fi
  if chmod 644 "$tmp" && mv -f "$tmp" "$JENKINS_JAR"; then ok "updated $(tildify "$JENKINS_JAR") to match $JENKINS_URL"; return 0; fi
  rm -f "$tmp"
  bad "couldn't replace $(tildify "$JENKINS_JAR")"
  return 1
}

cmd_upgrade() {
  banner "upgrade the tools this script installs"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes or --dry-run."; fi
  setup_path
  if under_rosetta; then
    warn "this terminal runs under Rosetta (Intel emulation), where Homebrew refuses to upgrade the Apple Silicon tools in $(tildify "$BREW_ARM")"
    note "So new terminals don't use Rosetta: quit Terminal, select it in Finder > Applications > Utilities, choose File > Get Info, and turn off Open using Rosetta."
    [ "$DRY_RUN" = 1 ] || native_restart || die "Run '$ME upgrade' again in a terminal that doesn't use Rosetta."
  fi
  run_step "This script" step_upgrade_self
  run_step "Homebrew" step_upgrade_brew
  run_step "SDKMAN, Java and Maven" step_upgrade_sdkman
  [ ! -s "$JENKINS_JAR" ] || run_step "Jenkins CLI" step_upgrade_jenkins
  print_summary
  [ -z "$(idea_app)" ] || say "IntelliJ IDEA updates itself and its plugins; accept its update prompts."
  ! summary_failed
}

# --- Repos: every repository in the workspace at a glance -----------------------------------------
REPO_JOBS=6 # fetches at once
repo_dirs() { find -L "$1" -maxdepth 3 -name .git -prune 2>/dev/null | sed 's#/\.git$##' | sort; } # DIR -> repositories up to two folders down, through symlinks
gone_branches() { # DIR -> "branch<TAB>state<TAB>main branch" for each local branch whose remote branch was deleted
  local dir="$1" b rem track cur def st
  cur=" $(git -C "$dir" worktree list --porcelain 2>/dev/null | sed -n 's#^branch refs/heads/##p' | tr '\n' ' ')" # checked out here or in another worktree
  git -C "$dir" for-each-ref --format="%(refname:short)$TAB%(upstream:remotename)$TAB%(upstream:track)" refs/heads 2>/dev/null |
    while IFS="$TAB" read -r b rem track; do
      if [ "$track" != "[gone]" ] || [ -z "$rem" ]; then continue; fi
      def="$(git -C "$dir" symbolic-ref --quiet --short "refs/remotes/$rem/HEAD" 2>/dev/null)" # set by git clone
      [ -n "$def" ] || for st in main master; do git -C "$dir" rev-parse --verify --quiet "refs/remotes/$rem/$st" >/dev/null && { def="$rem/$st"; break; }; done
      if case "$cur" in *" $b "*) true ;; *) false ;; esac; then st=current
      elif [ -z "$def" ]; then st=unknown
      elif git -C "$dir" merge-base --is-ancestor "refs/heads/$b" "refs/remotes/$def" 2>/dev/null; then st=merged
      else st=unmerged; fi
      printf '%s\t%s\t%s\n' "$b" "$st" "$def"
    done
}
repo_fetch() { # DIR REMOTE: fetch without any prompt; 124 if it took too long
  if [ -n "${GIT_SSH_COMMAND:-}${GIT_SSH:-}" ] || git -C "$1" config --get core.sshCommand >/dev/null 2>&1; then
    run_limited "$NET_TIMEOUT" git_quiet -C "$1" fetch --quiet --prune "$2"
  else # and ssh can't stop to ask for a passphrase or about a new host key
    GIT_SSH_COMMAND="ssh -o BatchMode=yes" run_limited "$NET_TIMEOUT" git_quiet -C "$1" fetch --quiet --prune "$2"
  fi
}

cmd_repos() {
  local ws dirs dir n=0 i first=-1 tmp w=4 bw=6 st out changed untracked dirty counts ahead behind msg level pnote pullable=0 failed=0
  local b def gone del="" ndel=0 fetch=1 gd repos_seen="|"
  local -a rdir rrel rbr rrem rdet
  ws="${WORKSPACE_DIR%"${WORKSPACE_DIR##*[!/]}"}"
  [ -n "$ws" ] || ws=/
  banner "status of every repository"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  [ -d "$ws" ] || die "There is no folder $(tildify "$ws"); pass --workspace PATH."
  git_ready || die "This needs git, from the Xcode Command Line Tools: run $ME install, or xcode-select --install."
  setup_path
  section "Repositories in $(tildify "$ws")"
  dirs="$(repo_dirs "$ws")"
  if [ -z "$dirs" ]; then warn "no git repositories here; pass --workspace PATH to look somewhere else"; return 0; fi
  while IFS= read -r dir <&3; do
    rdir[n]="$dir" rrel[n]="${dir#"$ws"/}" rrem[n]=""
    [ "$dir" != "$ws" ] || rrel[n]=.
    rdet[n]=0
    if rbr[n]="$(git -C "$dir" symbolic-ref --quiet --short HEAD 2>/dev/null)"; then
      rrem[n]="$(git -C "$dir" config --get "branch.${rbr[n]}.remote" 2>/dev/null)"
    else
      rdet[n]=1 rbr[n]="($(git -C "$dir" rev-parse --short HEAD 2>/dev/null || echo "no commits"))"
    fi
    [ "${#rrel[n]}" -le "$w" ] || w=${#rrel[n]}
    [ "${#rbr[n]}" -le "$bw" ] || bw=${#rbr[n]}
    case "${rrem[n]}" in "" | .) ;; *) [ "$first" -ge 0 ] || first=$n ;; esac
    n=$((n + 1))
  done 3<<EOF
$dirs
EOF
  [ "$w" -le 40 ] || w=40
  [ "$bw" -le 30 ] || bw=30
  tmp="$(mktemp -d)" || die "Couldn't make a temporary folder."
  if [ "$DO_FETCH" != 1 ] || [ "$DRY_RUN" = 1 ]; then
    fetch=0
    note "not fetching ($([ "$DO_FETCH" != 1 ] && echo "--no-fetch" || echo "dry run")), so this is what each repository knew at its last fetch"
  fi
  if [ "$first" -ge 0 ] && [ "$fetch" = 1 ]; then
    note "fetching from their remotes..."
    repo_fetch "${rdir[first]}" "${rrem[first]}" </dev/null >/dev/null 2>&1
    st=$?
    echo "$st" >"$tmp/$first"
    if [ "$st" = 124 ]; then # most likely off the VPN; don't wait that long for each of the others too
      note "fetching ${rrel[first]} took over ${NET_TIMEOUT}s (are you on the VPN?), so the others weren't fetched; they show what was known at their last fetch"
    else
      for ((i = first + 1; i < n; i++)); do
        case "${rrem[i]}" in "" | .) continue ;; esac
        while [ "$(jobs -rp | wc -l)" -ge "$REPO_JOBS" ]; do sleep 0.1; done
        (repo_fetch "${rdir[i]}" "${rrem[i]}" </dev/null >/dev/null 2>&1; echo $? >"$tmp/$i") &
      done
      wait
    fi
  fi
  for ((i = 0; i < n; i++)); do
    dir="${rdir[i]}" level=ok msg="" ahead=0 behind=0
    out="$(git --no-optional-locks -C "$dir" status --porcelain 2>/dev/null)"
    changed=0 untracked=0 dirty=""
    if [ -n "$out" ]; then
      untracked="$(printf '%s\n' "$out" | grep -c '^??')"
      changed="$(printf '%s\n' "$out" | grep -vc '^??')"
    fi
    [ "$changed" = 0 ] || dirty="$changed changed"
    [ "$untracked" = 0 ] || dirty="${dirty:+$dirty, }$untracked untracked"
    if [ "${rdet[i]}" = 1 ]; then
      level=skip msg="detached HEAD${dirty:+, $dirty}"
    elif [ -z "${rrem[i]}" ]; then
      level=skip msg="no upstream branch${dirty:+, $dirty}"
    elif ! git -C "$dir" rev-parse --verify --quiet '@{u}' >/dev/null 2>&1; then # deleted after its pull request merged?
      level=warn msg="its upstream branch is gone${dirty:+, $dirty}"
    else
      st="$(cat "$tmp/$i" 2>/dev/null)"
      case "$st" in
        0) ;;
        "") [ "${rrem[i]}" = . ] || [ "$fetch" = 0 ] || { msg="not fetched" level=info; } ;;
        124) msg="fetch timed out" level=warn ;;
        *) msg="couldn't fetch" level=warn ;;
      esac
      counts="$(git -C "$dir" rev-list --left-right --count 'HEAD...@{u}' 2>/dev/null)"
      ahead="${counts%%[!0-9]*}" behind="${counts##*[!0-9]}"
      ahead="${ahead:-0}" behind="${behind:-0}" pnote=""
      if [ "$ahead" -gt 0 ] && [ "$behind" -gt 0 ]; then
        msg="${msg:+$msg, }$ahead ahead and $behind behind; merge or rebase it yourself" level=warn
      elif [ "$behind" -gt 0 ]; then
        [ "$level" = warn ] || level=info
        if [ "$changed" != 0 ]; then
          [ "$DO_PULL" != 1 ] || pnote="not pulled because of the changes"
        elif [ "$DO_PULL" != 1 ]; then
          pullable=$((pullable + 1))
        elif [ "$DRY_RUN" = 1 ]; then
          pnote="would fast-forward"
        elif git_quiet -C "$dir" merge --ff-only --quiet '@{u}' </dev/null >/dev/null 2>&1; then
          pnote="pulled"
          [ "$level" = warn ] || level=ok
        else
          pnote="the fast-forward failed" level=warn failed=$((failed + 1))
        fi
        if [ "$pnote" = pulled ]; then msg="${msg:+$msg, }pulled $behind commit(s)" pnote=""; else msg="${msg:+$msg, }$behind behind"; fi
      elif [ "$ahead" -gt 0 ]; then
        msg="${msg:+$msg, }$ahead ahead, not pushed"
        [ "$level" = warn ] || level=info
      fi
      if [ -n "$dirty" ]; then
        msg="${msg:+$msg, }$dirty"
        [ "$level" != ok ] || level=info
      fi
      [ -z "$pnote" ] || msg="$msg; $pnote"
      [ -n "$msg" ] || msg="up to date"
    fi
    "$level" "$(printf "%-${w}s  %-${bw}s  %s" "${rrel[i]}" "${rbr[i]}" "$msg")"
  done
  rm -rf "$tmp"
  [ "$pullable" = 0 ] || say "$pullable can fast-forward (no changes, only behind): $ME repos --pull"
  # Local branches whose remote branch is gone, usually deleted when its pull request merged
  [ "$DO_CLEAN" != 1 ] || section "Local branches whose remote branch is gone"
  for ((i = 0; i < n; i++)); do
    gd="$(cd "${rdir[i]}" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)" # worktrees of one repository share it
    case "$repos_seen" in *"|$gd|"*) continue ;; esac
    repos_seen="$repos_seen$gd|"
    gone="$(gone_branches "${rdir[i]}")"
    [ -n "$gone" ] || continue
    while IFS="$TAB" read -r b st def; do
      case "$st" in
        merged) ndel=$((ndel + 1)) del="$del${rdir[i]}$TAB$b$NL"
          [ "$DO_CLEAN" != 1 ] || info "$(printf "%-${w}s  %s" "${rrel[i]}" "$b: merged into $def")" ;;
        current) [ "$DO_CLEAN" != 1 ] || skip "$(printf "%-${w}s  %s" "${rrel[i]}" "$b: kept, it's checked out (here or in another worktree); switch to another branch to delete it")" ;;
        unmerged) [ "$DO_CLEAN" != 1 ] || warn "$(printf "%-${w}s  %s" "${rrel[i]}" "$b: kept, it has commits that aren't in $def (if it was squash-merged: git -C $(tildify "${rdir[i]}") branch -D $b)")" ;;
        *) [ "$DO_CLEAN" != 1 ] || skip "$(printf "%-${w}s  %s" "${rrel[i]}" "$b: kept, couldn't tell which branch is the main one")" ;;
      esac
    done <<EOF
$gone
EOF
  done
  if [ "$DO_CLEAN" != 1 ]; then
    [ "$ndel" = 0 ] || say "$ndel merged branch(es) whose remote branch is gone can be deleted: $ME repos --clean-branches"
  elif [ "$ndel" = 0 ]; then
    ok "nothing to delete"
  elif [ "$DRY_RUN" = 1 ]; then
    note "Dry run: would delete the $ndel merged branch(es)."
  elif ask_yn "    Delete the $ndel merged branch(es)? Their commits are in the main branch." Y; then
    while IFS="$TAB" read -r dir b <&3; do
      [ -n "$dir" ] || continue
      if git -C "$dir" branch -D --quiet "$b" >/dev/null 2>&1; then ok "deleted $b in ${dir#"$ws"/}"
      else bad "couldn't delete $b in ${dir#"$ws"/} (checked out in another worktree?)"; failed=$((failed + 1)); fi
    done 3<<EOF
$del
EOF
  fi
  [ "$failed" = 0 ]
}

# --- Doctor --fix: repairs for what doctor finds ----------------------------------------------
fix_offer() { # LABEL Y|N|I CMD...: with --fix, remember a repair to offer once the check is done (once per command); I: only at the keyboard
  local label="$1" def="$2" cmd i
  [ "$DO_FIX" = 1 ] || return 0
  shift 2
  cmd="$(printf '%q ' "$@")" # never a secret: fixes read credentials when they run
  for ((i = 0; i < FIX_N; i++)); do [ "${FIX_CMD[i]}" != "$cmd" ] || return 0; done
  FIX_LABEL[FIX_N]="$label" FIX_DEF[FIX_N]="$def" FIX_CMD[FIX_N]="$cmd"
  FIX_N=$((FIX_N + 1))
}
# FIX_CMD -> "function item|function item|": what a fix is for, one item at a time. A fix such as
# "fix_credentials nexus dtr" comes back as "fix_credentials dtr" when only Nexus was entered.
fix_items() {
  local f v
  eval "set -- $1" # FIX_CMD was written with printf %q
  f="$1"
  shift
  if [ $# -eq 0 ]; then printf '%s|' "$f"; return; fi
  for v in "$@"; do printf '%s %s|' "$f" "$v"; done
}
rc_names() { local f out=""; while IFS= read -r f; do out="$out and $(tildify "$f")"; done < <(rc_files); printf '%s' "${out# and }"; }
blocks_fixable() { local f; while IFS= read -r f <&3; do markers_ok "$f" || return 1; done 3< <(rc_files); } # write_block refuses damaged blocks
blocks_current() { # 0 if each profile rc_files lists has the managed block as this version writes it
  local f
  while IFS= read -r f <&3; do
    [ -f "$f" ] || return 1
    awk -v b="$MARK_BEGIN" -v e="$MARK_END" '$0 == b { s = 1 } s { print } s && $0 == e { exit }' "$f" | cmp -s - <(managed_block) || return 1
  done 3< <(rc_files)
}
offer_block_fix() { [ "$DO_FIX" = 1 ] && blocks_fixable || return 0; blocks_current || fix_offer "Add or refresh the ngss-dev-setup block in $(rc_names)" Y fix_shell_block; }
offer_intellij_fix() { [ "$DO_FIX" = 1 ] || return 0; fix_offer "Install the missing IntelliJ plugins and settings, as '$ME intellij' does (quit IntelliJ first)" Y step_intellij; }
also_set() { local l; l="$(outside_defs "$1")"; [ -z "$l" ] || printf ' (also set in %s)' "$l"; } # VAR -> where else a profile sets it
local_contexts() { kubectl config get-contexts -o name 2>/dev/null | grep -Ex 'orbstack|docker-desktop'; }
offer_context_fix() { [ "$DO_FIX" = 1 ] && [ -n "$(local_contexts)" ] && fix_offer "$1" "$2" fix_kube_context; } # LABEL Y|N
offer_runtime_fix() { # start whichever runtime the docker CLI points at, else the one installed
  [ "$DO_FIX" = 1 ] || return 0
  if [ "$(docker context show 2>/dev/null)" = desktop-linux ] && app_present docker-desktop; then
    fix_offer "Start Docker Desktop and wait for its Kubernetes" Y start_docker_desktop
  elif app_present orbstack || has orb; then
    fix_offer "Start OrbStack, with its Kubernetes on" Y start_orbstack
  elif app_present docker-desktop; then
    fix_offer "Start Docker Desktop and wait for its Kubernetes" Y start_docker_desktop
  elif ! has docker; then # a docker CLI without either app means another runtime (Colima, Rancher Desktop...), so leave it be
    fix_offer "Install OrbStack with Homebrew and start it, with its Kubernetes on" Y fix_install_orbstack
  fi
}

fix_git_identity() {
  local name email rc=0
  if [ "$DRY_RUN" = 1 ]; then dry "ask for your name and email, then set git's user.name and user.email"; return 0; fi
  name="$(git config --global --get user.name 2>/dev/null)"
  email="$(git config --global --get user.email 2>/dev/null)"
  [ -n "$name" ] || name="$(id -F 2>/dev/null)"
  name="$(ask "    Your name for git commits" "$name")"
  email="$(ask "    Your Apothesource email for git commits" "$email")"
  if [ -n "$name" ]; then git_set user.name "$name" || rc=1; else warn "user.name is still unset"; rc=2; fi
  if [ -n "$email" ]; then git_set user.email "$email" || rc=1; else warn "user.email is still unset"; [ "$rc" = 1 ] || rc=2; fi
  return $rc
}
fix_dispatcher() { # HOOKS-DIR: replace the old Modular Git Hooks dispatcher, which hid the last pushed branch from the hooks
  local f="$1/dispatch"
  if [ "$DRY_RUN" = 1 ]; then dry "replace $(tildify "$f") with the current dispatcher (a backup is kept)"; return 0; fi
  backup "$f"
  if hook_dispatch >"$f" && chmod +x "$f"; then ok "replaced $(tildify "$f")"; else bad "couldn't write $(tildify "$f")"; return 1; fi
}
fix_hooks() { # HOOKS-DIR: replace each hook there that is an unchanged earlier version
  local rel f rc=0
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    f="$1/$rel"
    if [ "$DRY_RUN" = 1 ]; then dry "replace $(tildify "$f") with the current version (a backup is kept)"; continue; fi
    backup "$f"
    if "$(hook_function "$rel")" >"$f" && chmod +x "$f"; then ok "replaced $(tildify "$f")"; else bad "couldn't write $(tildify "$f")"; rc=1; fi
  done < <(outdated_hooks "$1")
  return $rc
}
fix_push_scan() { # HOOKS-DIR: replace the old pre-push secret scan
  local f="$1/pre-push.d/20-trufflehog-full-scan"
  if [ "$DRY_RUN" = 1 ]; then dry "replace $(tildify "$f") with the current version (a backup is kept)"; return 0; fi
  backup "$f"
  if hook_trufflehog_full_scan >"$f" && chmod +x "$f"; then ok "replaced $(tildify "$f")"; else bad "couldn't write $(tildify "$f")"; return 1; fi
}
fix_brew() { # FORMULA...: install with Homebrew, then check each command is there
  local f rc=0
  has brew || { bad "Homebrew isn't installed"; return 1; }
  brew_install_formulae "$@"
  case $? in 0) ;; 2) return 2 ;; *) rc=1 ;; esac
  [ "$DRY_RUN" != 1 ] || return 0
  for f in "$@"; do if has "$(formula_cmd "$f")"; then ok "$f"; else bad "$f didn't install"; rc=1; fi; done
  return $rc
}
fix_java() { decide_java_default; step_java; } # SDKMAN was missing, so everything it installs is too
fix_install_orbstack() { # no container runtime at all: install OrbStack, the default, and start it
  has brew || { bad "Homebrew isn't installed"; return 1; }
  run brew install --cask orbstack || { bad "brew install --cask orbstack failed"; return 1; }
  [ "$DRY_RUN" = 1 ] || app_present orbstack || { bad "OrbStack didn't install"; return 1; }
  start_orbstack
}
cred_titles() { # IDS... -> "Bitbucket (Coderepo), VA Nexus and Sandbox DTR"
  local id out="" n=0
  for id in "$@"; do
    cred_meta "$id"
    n=$((n + 1))
    if [ "$n" = 1 ]; then out="$C_TITLE"; elif [ "$n" = $# ]; then out="$out and $C_TITLE"; else out="$out, $C_TITLE"; fi
  done
  printf '%s' "$out"
}
fix_credentials() { # IDS...: ask for these credentials, try each against its server, and save them as '$ME credentials' does
  local r
  if [ "$DRY_RUN" = 1 ]; then dry "ask for the $(cred_titles "$@") credentials, then save them as '$ME credentials' does"; return 0; fi
  gather_credentials "$@"
  section "Saving"
  save_credentials
  r=$?
  [ "$r" != 3 ] || r=1
  return $r
}
fix_sdk() { sdk_init || { bad "SDKMAN isn't installed"; return 1; }; ensure_sdk "$@"; } # CANDIDATE VERSION
fix_default_java() {
  fix_sdk java "$JAVA_DEFAULT" || return 1
  if [ "$DRY_RUN" = 1 ]; then dry "sdk default java $SDK_PICKED"; return 0; fi
  if sdk_run default java "$SDK_PICKED" >/dev/null 2>&1; then ok "default java: $SDK_PICKED"; else bad "couldn't make java $SDK_PICKED the default"; return 1; fi
}
fix_maven() {
  fix_sdk maven "$MAVEN_VERSION" || return 1
  [ -z "$(sdk_current maven)" ] || return 0
  if [ "$DRY_RUN" = 1 ]; then dry "sdk default maven $SDK_PICKED"; return 0; fi
  if sdk_run default maven "$SDK_PICKED" >/dev/null 2>&1; then ok "default maven: $SDK_PICKED"; else bad "couldn't make maven $SDK_PICKED the default"; return 1; fi
}
fix_shell_block() {
  local f rc=0
  while IFS= read -r f <&3; do write_block "$f" || rc=1; done 3< <(rc_files)
  return $rc
}
fix_cred_perms() {
  if [ "$DRY_RUN" = 1 ]; then dry "chmod 600 $(tildify "$CRED_FILE")"; return 0; fi
  if chmod 600 "$CRED_FILE"; then ok "$(tildify "$CRED_FILE") is private (600)"; else bad "couldn't chmod $(tildify "$CRED_FILE")"; return 1; fi
}
fix_outside_creds() { # copy values only the profile has into credentials.env, then disable the profile lines
  local v val moved=0 vars="" left="" rc=0
  load_fresh_env # an earlier fix may have changed what new terminals get
  for v in $CRED_VARS; do
    [ -n "$(outside_defs "$v")" ] || continue
    if [ -z "$(file_value "$v")" ]; then # otherwise credentials.env has one, and it wins from now on
      val="$(cval "$v")"
      # A line a new terminal doesn't run (e.g. ~/.zprofile with bash) gives no value to move, so keep it
      if [ -z "$val" ]; then left="$left $v"; continue; fi
      printf -v "$v" '%s' "$val"
      moved=1
    fi
    vars="$vars $v"
  done
  if [ "$moved" = 1 ]; then write_credentials || { bad "couldn't save $(tildify "$CRED_FILE")"; return 1; }; fi
  if [ -n "$vars" ]; then disable_outside_creds noask "${vars# }"; rc=$?; fi
  if [ -n "$left" ]; then
    warn "left$left alone in $(outside_defs "${left# }"): a new terminal doesn't get the value, so there was nothing to move ($ME credentials can save it)"
    [ "$rc" = 1 ] || rc=2
  fi
  return $rc
}
fix_maven_settings() {
  local f="$HOME/.m2/settings.xml"
  if [ "$DRY_RUN" = 1 ]; then dry "write ~/.m2/settings.xml from the onboarding template$([ ! -f "$f" ] || printf ' (a backup is kept)')"; return 0; fi
  write_settings_xml
}
fix_kube_context() { # make the local cluster kubectl's current context, since skaffold deploys there
  local c want="" names
  names="$(local_contexts)"
  for c in $names; do kube_ready "$c" && { want="$c"; break; }; done
  [ -n "$want" ] || want="$(printf '%s\n' "$names" | head -n 1)"
  [ -n "$want" ] || { bad "kubectl has no orbstack or docker-desktop context; turn on Kubernetes in OrbStack or Docker Desktop"; return 1; }
  if [ "$(current_context)" = "$want" ]; then ok "kubectl context: $want"; return 0; fi
  if [ "$DRY_RUN" = 1 ]; then dry "kubectl config use-context $want"; return 0; fi
  if kubectl config use-context "$want" >/dev/null 2>&1; then ok "kubectl context: $want"; else bad "couldn't switch kubectl to $want"; return 1; fi
}
fix_git_login() { # save what a new terminal has for VA_BITBT_USER / VA_BITBT_PWD as git's Coderepo login
  local u p
  load_fresh_env # an earlier fix may have changed what new terminals get
  u="$(cval VA_BITBT_USER)" p="$(cval VA_BITBT_PWD)"
  if [ -z "$u" ] || [ -z "$p" ]; then warn "VA_BITBT_USER and VA_BITBT_PWD aren't both set in a new terminal: $ME credentials"; return 2; fi
  git_save_coderepo "$u" "$p"
}

doctor_fix() { # offer each repair doctor found, then check again
  local i label out ran=0 seen more v
  section "Fixes"
  if [ "$FIX_N" = 0 ]; then
    if [ $((N_BAD + N_WARN)) -gt 0 ]; then say "Nothing above can be fixed automatically; each problem says what to do."; else say "Nothing to fix."; fi
    return 0
  fi
  say "$FIX_N fix(es) for what doctor found. Problems not listed here say above what to do."
  for ((i = 0; i < FIX_N; i++)); do
    label="$((i + 1))/$FIX_N ${FIX_LABEL[i]}"
    if [ "$DRY_RUN" = 1 ]; then
      case "${FIX_DEF[i]}" in N) label="$label (asks; default no)" ;; I) label="$label (asks you for them)" ;; esac
      say "$label"
    elif [ "$ASSUME_YES" = 1 ]; then
      case "${FIX_DEF[i]}" in
        N) skip "$label (not done with --yes; its default is no)"; continue ;;
        I) skip "$label (needs you at the keyboard; run doctor --fix without --yes)"; continue ;;
      esac
      say "$label"
    elif ! ask_yn "    $label?" "$([ "${FIX_DEF[i]}" = N ] && echo N || echo Y)"; then
      continue
    fi
    eval "${FIX_CMD[i]}"
    ran=1
  done
  [ "$DRY_RUN" != 1 ] && [ "$ran" = 1 ] || return 0
  section "Checking again"
  seen="|" # what this round offered, item by item, so the check below can tell what the fixes uncovered
  for ((i = 0; i < FIX_N; i++)); do seen="$seen$(fix_items "${FIX_CMD[i]}")"; done
  FIX_N=0 FIX_LABEL=() FIX_DEF=() FIX_CMD=()
  out="$(mktemp)"
  doctor_all >"$out" 2>&1 # still with --fix, which only collects what could be fixed now
  # repeat only what is still wrong
  awk -v esc="$(printf '\033')" '{ p = $0; gsub(esc "\\[[0-9;]*m", "", p); if (p ~ /^    (warn|FAIL)  /) print }' "$out"
  rm -f "$out"
  [ $((N_BAD + N_WARN)) -gt 0 ] || say "Nothing left to fix."
  more=0
  for ((i = 0; i < FIX_N; i++)); do
    while IFS= read -r v; do
      [ -n "$v" ] || continue
      case "$seen" in *"|$v|"*) ;; *) more=$((more + 1)); break ;; esac
    done < <(fix_items "${FIX_CMD[i]}" | tr '|' '\n')
  done
  [ "$more" = 0 ] || say "With those done, doctor found $more more to fix: run $ME doctor --fix again."
  printf '\n    %sdoctor: %d ok, %d warning(s), %d problem(s)%s\n' "$B" "$N_OK" "$N_WARN" "$N_BAD" "$RST"
}

# --- Doctor (read-only unless --fix) -------------------------------------------------------------
# shellcheck disable=SC2016 # the probe code runs in the child shell
fresh_probe() ( # start a login shell like a new Terminal window and report what it sets; NGSS_TIMEOUT if it hung
  sh=zsh
  case "${SHELL:-}" in */bash) sh=bash ;; esac
  sh="$(command -v "$sh")" || exit 1
  if [ "${sh##*/}" = zsh ]; then
    code='for v in '"$PROBE_VARS"'; do
  if [[ -n ${(P)v-} ]]; then print -r -- "NGSS:$v:$(print -rn -- "${(P)v}" | base64 | tr -d "\n")"; else print -r -- "NGSS:$v:"; fi
done
(( ${+functions[jcli]} )) && print -r -- "NGSS:JCLI_FUNC:eWVz"
true'
  else
    code='for v in '"$PROBE_VARS"'; do
  if [ -n "${!v-}" ]; then printf "NGSS:%s:%s\n" "$v" "$(printf %s "${!v}" | base64 | tr -d "\n")"; else printf "NGSS:%s:\n" "$v"; fi
done
declare -F jcli >/dev/null && printf "NGSS:JCLI_FUNC:eWVz\n"
true'
  fi
  # A file rather than a pipe, so a stuck child of the shell can't keep us waiting after the time limit
  out="$(mktemp)" || exit 1
  trap 'rm -f "$out"' EXIT
  run_limited "$PROBE_TIMEOUT" env -i HOME="$HOME" USER="${USER:-}" LOGNAME="${LOGNAME:-${USER:-}}" SHELL="${SHELL:-/bin/zsh}" \
    TERM="${TERM:-xterm-256color}" LANG="${LANG:-en_US.UTF-8}" PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
    DISABLE_AUTO_UPDATE=true "$sh" -l -i -c "$code" </dev/null >"$out" 2>/dev/null
  [ $? -ne 124 ] || echo "NGSS_TIMEOUT"
  grep '^NGSS:' "$out"
)
load_fresh_env() { # FRESH_<VAR> = value in a new terminal; values stay in memory, never printed
  local line name enc v
  for v in $PROBE_VARS JCLI_FUNC; do printf -v "FRESH_$v" '%s' ""; done
  FRESH_OK=0 FRESH_TIMEOUT=0
  while IFS= read -r line; do
    if [ "$line" = NGSS_TIMEOUT ]; then FRESH_TIMEOUT=1; continue; fi
    line="${line#NGSS:}"
    name="${line%%:*}"
    enc="${line#*:}"
    case " $PROBE_VARS JCLI_FUNC " in *" $name "*) ;; *) continue ;; esac
    v=""
    [ -z "$enc" ] || v="$(printf '%s' "$enc" | base64 --decode 2>/dev/null)"
    printf -v "FRESH_$name" '%s' "$v"
    FRESH_OK=1
  done < <(fresh_probe)
}
fresh() { local n="FRESH_$1"; printf '%s' "${!n:-}"; }
cval() { # VAR -> value in a new terminal, or in this process if that couldn't be checked
  local n="$1"
  if [ "$FRESH_OK" = 1 ]; then fresh "$n"; else printf '%s' "${!n:-}"; fi
}
file_value() { # VAR -> its value in credentials.env
  [ -f "$CRED_FILE" ] || return 0
  # shellcheck disable=SC2086,SC1090
  (unset $CRED_VARS; set +u; . "$CRED_FILE" >/dev/null 2>&1; printf '%s' "${!1:-}")
}

doctor_system() {
  local v l old=0
  section "System"
  check_latest verbose
  ok "macOS $(sw_vers -productVersion 2>/dev/null) on $(uname -m)"
  if clt_ready; then ok "Xcode Command Line Tools"; else bad "Xcode Command Line Tools missing: xcode-select --install"; fix_offer "Install the Xcode Command Line Tools" Y step_clt; fi
  under_rosetta && warn "this shell runs under Rosetta (Intel emulation), where Homebrew won't work with the Apple Silicon tools and SDKMAN picks Intel JDKs; turn off Open using Rosetta in Terminal's Get Info"
  if arm_mac && [ ! -x "$BREW_ARM/bin/brew" ]; then
    bad "Homebrew for Apple Silicon isn't installed in $(tildify "$BREW_ARM")"
    fix_offer "Install Homebrew in $(tildify "$BREW_ARM") (asks for your Mac password)" Y step_homebrew
  elif has brew; then
    ok "$(brew --version 2>/dev/null | head -n 1)"
  else
    bad "Homebrew missing: https://brew.sh"
    fix_offer "Install Homebrew (asks for your Mac password)" Y step_homebrew
  fi
  if intel_brew_on_arm; then
    warn "an Intel copy of Homebrew is in $(tildify "$BREW_X86"), probably from Migration Assistant; its tools need Rosetta (Intel emulation) to run, and build from source (to remove it: https://github.com/Homebrew/install#uninstall-homebrew)"
    if [ -x "$BREW_ARM/bin/brew" ] && [ "$FRESH_OK" = 1 ] && brew_path_wrong; then
      if brew_line_last; then # Homebrew's own setup is right, so something run after it moves the Intel one back in front
        v="$(mentions "$BREW_X86/bin")"
        warn "new terminals find $(tildify "$BREW_X86")/bin before $(tildify "$BREW_ARM")/bin, so they run the Intel Homebrew and its tools; a startup line after Homebrew's setup puts it first${v:+ (look at $v)}"
      else
        warn "new terminals find $(tildify "$BREW_X86")/bin before $(tildify "$BREW_ARM")/bin, so they run the Intel Homebrew and its tools"
        fix_offer "Put $(tildify "$BREW_ARM") first in new terminals (adds its brew shellenv line to the end of $(tildify "$(login_profile)"))" Y add_brew_line
      fi
    fi
  fi
  v="$(free_kb "$HOME")"
  if [ -n "$v" ] && [ "$v" -lt "$DOCTOR_LOW_KB" ]; then warn "only $(human_kb "$v") free on your disk; builds and container images need room ('$ME tidy' shows what it can remove)"
  elif [ -n "$v" ]; then ok "$(human_kb "$v") free on your disk"; fi
  if va_reachable; then ok "$CODEREPO_HOST is reachable (VA network)"; else warn "$CODEREPO_HOST isn't reachable; if you're off-network, connect to the VPN"; fi
  git_ready || { bad "git doesn't work yet"; return 0; }
  v="$(git config --global --get user.name 2>/dev/null)"
  if [ -n "$v" ]; then ok "git user.name: $v"; else warn "git user.name isn't set"; fix_offer "Set your name and email for git commits" Y fix_git_identity; fi
  v="$(git config --global --get user.email 2>/dev/null)"
  case "$v" in
    *@apothesource.com) ok "git user.email: $v" ;;
    "") warn "git user.email isn't set (use your @apothesource.com address)"; fix_offer "Set your name and email for git commits" Y fix_git_identity ;;
    *) warn "git user.email is $v; the docs use your @apothesource.com address"; fix_offer "Change the name and email on your git commits" N fix_git_identity ;;
  esac
  if [ "$(git config --global --get core.trustctime 2>/dev/null)" = false ]; then ok "git core.trustctime=false"
  else warn "git core.trustctime isn't false: git config --global core.trustctime false"; fix_offer "Set git core.trustctime=false" Y git_set core.trustctime false; fi
  v="$(untildify "$(git config --global --get core.hooksPath 2>/dev/null)")"
  [ -n "$v" ] || return 0
  if [ -f "$v/dispatch" ] && grep -qF "$OLD_DISPATCH_LINE" "$v/dispatch"; then
    warn "$(tildify "$v")/dispatch is the old version from the Modular Git Hooks page, so pushes to main aren't blocked (fix: $ME install)"
    fix_offer "Replace the old git hooks dispatcher; pushes to main are then blocked, as the Modular Git Hooks page intends" N fix_dispatcher "$v"
    old=1
  fi
  if old_push_scan "$v"; then
    warn "$(tildify "$v")/pre-push.d/20-trufflehog-full-scan is an old version that can scan nothing and still report no secrets (fix: $ME install)"
    fix_offer "Replace the old pre-push secret scan with the current one (a backup is kept)" Y fix_push_scan "$v"
    old=1
  fi
  l="$(outdated_hooks "$v" | paste -sd ',' - | sed 's/,/, /g')"
  if [ -n "$l" ]; then
    warn "earlier versions of git hooks in $(tildify "$v"): $l; the current ones also check renamed files, scan what is staged, and run each repository's own hooks (fix: $ME install)"
    fix_offer "Update the git hooks to the current versions (backups are kept)" Y fix_hooks "$v"
    old=1
  fi
  if [ -f "$v/dispatch" ]; then
    l="$(unlinked_hooks "$v" | paste -sd ',' - | sed 's/,/, /g')"
    if [ -n "$l" ]; then
      warn "git skips each repository's own $l hooks (such as Git LFS's post-checkout), because the global core.hooksPath hides them"
      fix_offer "Link those hook events to the dispatcher, which runs each repository's own hooks" Y link_pass_hooks "$v"
      old=1
    fi
  fi
  [ "$old" = 1 ] || ok "global git hooks: $(tildify "$v")"
}

mentions() { # TEXT -> "~/.zshrc:12,30 ~/.bashrc:4": shell startup lines that contain TEXT, comments aside
  local f lines out=""
  while IFS= read -r f <&3; do
    [ -n "$f" ] || continue
    lines="$(awk -v t="$1" 'index($0, t) && $0 !~ /^[[:space:]]*#/ { printf "%s%d", (n++ ? "," : ""), NR }' "$f")"
    [ -z "$lines" ] || out="$out $(tildify "$f"):$lines"
  done 3<<EOF
$(profile_files)
EOF
  printf '%s' "${out# }"
}
norm_dirs() { sed -E 's#/+#/#g; s#([^:])/(:|$)#\1\2#g'; } # PATH-style text -> the same folders without doubled or trailing slashes
brew_path_wrong() { # 0 if a new terminal finds the Intel Homebrew before Apple Silicon's
  local p a x arm x86
  p=":$(fresh PATH | norm_dirs):"
  arm="$(printf '%s' "$BREW_ARM/bin" | norm_dirs)" x86="$(printf '%s' "$BREW_X86/bin" | norm_dirs)"
  case "$p" in *":$x86:"*) ;; *) return 1 ;; esac
  case "$p" in *":$arm:"*) ;; *) return 0 ;; esac
  a="${p%%":$arm:"*}" x="${p%%":$x86:"*}"
  [ "${#x}" -lt "${#a}" ]
}

check_latest() { # [verbose]: compare with the copy published on the onboarding site
  local published
  published="$(curl -fsS --max-time 10 "$SCRIPT_URL" 2>/dev/null | sed -n 's/^VERSION="\(.*\)"$/\1/p' | head -n 1)"
  if [ -z "$published" ]; then
    [ -z "${1:-}" ] || skip "couldn't check the onboarding site for a newer $ME"
    return 0
  fi
  if version_newer "$published" "$VERSION"; then
    warn "$ME $published is available (this is $VERSION): $ME update"
  elif [ -n "${1:-}" ]; then
    ok "$ME $VERSION is the latest"
  fi
}

doctor_tools() {
  local c v apps="" none="" formulae=""
  section "Tools"
  for c in git skaffold kustomize docker kubectl trufflehog; do
    # Without the Command Line Tools, /usr/bin/git only opens their install dialog
    if [ "$c" = git ] && ! clt_ready; then
      bad "git needs the Xcode Command Line Tools: run $ME install (or xcode-select --install)"
      fix_offer "Install the Xcode Command Line Tools" Y step_clt
      continue
    fi
    if ! has "$c"; then
      case "$c" in
        docker) bad "docker not found; it comes with OrbStack (or Docker Desktop)"
          app_present orbstack || app_present docker-desktop || offer_runtime_fix ;;
        kubectl) bad "kubectl not found"; formulae="$formulae kubernetes-cli" ;;
        trufflehog) bad "trufflehog not found, so the git hooks' secret scans skip themselves"; formulae="$formulae trufflehog" ;;
        *) bad "$c not found"; formulae="$formulae $c" ;;
      esac
      continue
    fi
    case "$c" in
      git) v="$(git --version 2>/dev/null)" ;;
      kubectl) v="$(kubectl version --client 2>/dev/null | head -n 1)" ;;
      docker) v="$(docker --version 2>/dev/null)" ;;
      trufflehog) v="$(trufflehog --version 2>&1 | head -n 1)" ;;
      *) v="$c $("$c" version 2>/dev/null | head -n 1)" ;;
    esac
    ok "$(printf '%s' "$v" | cut -c1-70)"
  done
  # shellcheck disable=SC2086 # the list splits into words
  [ -z "$formulae" ] || fix_offer "Install $(commas $formulae) with Homebrew" Y fix_brew $formulae
  for c in jq wget plantuml jira; do if has "$c"; then apps="$apps $c"; else none="$none $c"; fi; done
  [ -z "$apps" ] || ok "also installed:$apps"
  [ -z "$none" ] || skip "not installed:$none"
  if has plantuml; then
    if has dot; then ok "Graphviz: $(command -v dot) (IntelliJ's PlantUML setting wants this path)"
    else warn "Graphviz (dot) is missing; PlantUML needs it"; fix_offer "Install Graphviz with Homebrew" Y fix_brew graphviz; fi
  fi
  apps="" none=""
  # No Docker Desktop entry: OrbStack is the default, and doctor_runtime checks whichever engine is running.
  for c in orbstack headlamp intellij-idea postman; do
    v="$(cask_apps "$c" | cut -d'|' -f1)"
    if app_present "$c"; then apps="$apps, ${v%.app}"; else none="$none, ${v%.app}"; fi
  done
  [ -z "$apps" ] || ok "apps: ${apps#, }"
  [ -z "$none" ] || skip "apps not installed: ${none#, }"
  if has brew && clt_ready && brew_scan && [ -n "$UP_FN$UP_CN" ]; then
    # shellcheck disable=SC2086 # the lists split into words
    info "upgrades available for $(commas $UP_FN $UP_CN): $ME upgrade"
  fi
}

doctor_java() {
  local v cur blocked=0
  section "Java and Maven (SDKMAN)"
  if ! sdk_init; then
    if [ ! -s "$SDKMAN_DIR/bin/sdkman-init.sh" ] && { [ -e "$SDKMAN_DIR" ] || [ -L "$SDKMAN_DIR" ]; }; then
      bad "$(tildify "$SDKMAN_DIR") is incomplete (no bin/sdkman-init.sh), perhaps from an interrupted install; '$ME install' moves it aside and installs SDKMAN again"
      fix_offer "Move the incomplete $(tildify "$SDKMAN_DIR") aside and install SDKMAN" Y step_sdkman
    else
      bad "SDKMAN isn't installed (Development Environment Setup > SDKMAN)"
      fix_offer "Install SDKMAN" Y step_sdkman
    fi
    fix_offer "Install java $JAVA_EXTRA and $JAVA_DEFAULT, maven $MAVEN_VERSION and gradle with SDKMAN" Y fix_java
    return 0
  fi
  ok "SDKMAN $(sdkman_version)"
  if sdk_platform_wrong; then
    bad "SDKMAN is set up for Intel Macs (darwinx64) on this Apple Silicon Mac, so its JDKs are Intel builds that need Rosetta"
    fix_offer "Switch SDKMAN to Apple Silicon builds and reinstall its JDKs" Y fix_sdk_platform
  fi
  if grep -qx 'sdkman_auto_env=true' "$SDKMAN_DIR/etc/config" 2>/dev/null; then ok "sdkman_auto_env=true"
  else
    warn "sdkman_auto_env is off, so the JDK won't follow each repo's .sdkmanrc (set it in ~/.sdkman/etc/config)"
    fix_offer "Set sdkman_auto_env=true, so the JDK follows each repo's .sdkmanrc" Y sdk_config sdkman_auto_env true
  fi
  if sdk_has java "$JAVA_DEFAULT"; then ok "java $JAVA_DEFAULT installed"
  else bad "java $JAVA_DEFAULT isn't installed: sdk install java $JAVA_DEFAULT"; fix_offer "Install java $JAVA_DEFAULT" Y fix_sdk java "$JAVA_DEFAULT"; fi
  if sdk_has java "$JAVA_EXTRA"; then ok "java $JAVA_EXTRA installed"
  else warn "java $JAVA_EXTRA isn't installed (service-lint pins it): sdk install java $JAVA_EXTRA"; fix_offer "Install java $JAVA_EXTRA" Y fix_sdk java "$JAVA_EXTRA"; fi
  cur="$(sdk_current java)"
  case "$cur" in
    "${JAVA_DEFAULT%%.*}".*) ok "default java: $cur" ;; # any build of the docs' major version
    "") bad "no default java: sdk default java $JAVA_DEFAULT"; fix_offer "Make java $JAVA_DEFAULT the default" Y fix_default_java; blocked=1 ;;
    *) warn "default java is $cur; the docs use $JAVA_DEFAULT (sdk default java $JAVA_DEFAULT)"; fix_offer "Make java $JAVA_DEFAULT the default instead of $cur" N fix_default_java ;;
  esac
  if ! sdk_has maven "$MAVEN_VERSION"; then
    bad "maven $MAVEN_VERSION isn't installed: sdk install maven $MAVEN_VERSION"; fix_offer "Install maven $MAVEN_VERSION" Y fix_maven; blocked=1
  elif [ -z "$(sdk_current maven)" ]; then
    bad "maven $MAVEN_VERSION is installed, but SDKMAN has no default maven, so mvn isn't found: sdk default maven $MAVEN_VERSION"
    fix_offer "Make maven $MAVEN_VERSION the default" Y fix_maven; blocked=1
  else ok "maven $MAVEN_VERSION installed (default $(sdk_current maven))"; fi
  v="$(mvn -v 2>/dev/null | head -n 1 | sed 's/ (.*//')"
  if [ -n "$v" ]; then ok "mvn runs: $v"
  elif [ "$blocked" = 1 ]; then skip "mvn can't run until the problems above are fixed"
  else bad "mvn -v failed; run 'mvn -v' in a new terminal to see why"; fi
  if [ -n "$(ls "$SDKMAN_DIR/candidates/gradle" 2>/dev/null)" ]; then ok "gradle $(sdk_current gradle)"; else skip "gradle isn't installed"; fi
}

doctor_shell() {
  local f v val have="" missing="" opt="" fv locs stray clash
  section "Shell environment (what a new terminal gets)"
  case "${SHELL##*/}" in
    zsh | bash) ;;
    *) warn "your login shell is ${SHELL:-unknown}, and this script sets up zsh and bash; add the same settings to your shell's startup file" ;;
  esac
  if [ "$FRESH_TIMEOUT" = 1 ]; then
    warn "a new ${SHELL##*/} took over ${PROBE_TIMEOUT}s to start, so this checks the current process instead; something in your shell startup files is slow or waiting for input"
  elif [ "$FRESH_OK" != 1 ]; then
    warn "couldn't start a fresh ${SHELL##*/} to look, so this checks the current process instead"
  fi
  while IFS= read -r f <&3; do v="$(markers_problem "$f")"; [ -z "$v" ] || bad "$v"; done 3< <(profile_files)
  val="$(cval SKAFFOLD_PUSH)"
  if [ "$val" = false ]; then ok "SKAFFOLD_PUSH=false"; else bad "SKAFFOLD_PUSH is '${val:-unset}'; it must be false$(also_set SKAFFOLD_PUSH)"; offer_block_fix; fi
  val="$(cval SKAFFOLD_RPC_PORT)"
  if [ "$val" = 50051 ]; then ok "SKAFFOLD_RPC_PORT=50051"; else warn "SKAFFOLD_RPC_PORT is '${val:-unset}'; the docs use 50051$(also_set SKAFFOLD_RPC_PORT)"; offer_block_fix; fi
  val="$(cval DTR_URL)"
  if [ "$val" = "$DTR_HOST" ]; then ok "DTR_URL=$DTR_HOST"; else warn "DTR_URL is '${val:-unset}'; the docs use $DTR_HOST$(also_set DTR_URL)"; offer_block_fix; fi
  for v in VA_BITBT_USER VA_BITBT_PWD VA_NEXUS_USER VA_NEXUS_PWD DTR_USER DTR_PWD; do
    if [ -n "$(cval "$v")" ]; then have="$have $v"; else missing="$missing $v"; fi
  done
  for v in JIRA_API_TOKEN JENKINS_API_USER JENKINS_API_TOKEN; do
    if [ -n "$(cval "$v")" ]; then have="$have $v"; else opt="$opt $v"; fi
  done
  [ -z "$have" ] || ok "set:$have"
  [ -z "$missing" ] || bad "not set:$missing (fix with: $ME credentials)"
  [ -z "$opt" ] || skip "not set:$opt (only the Jira and Jenkins CLIs use these)"
  [ -f "$CRED_FILE" ] || return 0
  if [ "$(stat -f %Lp "$CRED_FILE")" = 600 ]; then ok "$(tildify "$CRED_FILE") is private (600)"
  else warn "make it private: chmod 600 $(tildify "$CRED_FILE")"; fix_offer "Make $(tildify "$CRED_FILE") private (chmod 600)" Y fix_cred_perms; fi
  stray="$(outside_defs "$CRED_VARS")"
  if [ "$FRESH_OK" = 1 ]; then
    for v in $CRED_VARS; do
      fv="$(file_value "$v")"
      [ -n "$fv" ] || continue
      [ "$(fresh "$v")" != "$fv" ] || continue
      locs="$(outside_defs "$v")"
      if [ -n "$locs" ]; then
        bad "$v in a new terminal isn't the value in credentials.env; it is also set in $locs (run '$ME credentials' to choose)"
        fix_offer "Keep the credentials.env values and disable the lines that override them in $stray (backups kept)" N fix_outside_creds
      else
        bad "$v in a new terminal isn't the value in credentials.env"
        offer_block_fix
      fi
    done
  fi
  if [ -n "$stray" ]; then
    warn "credentials are also set outside credentials.env: $stray (run '$ME credentials' to tidy up)"
    # Moving keeps credentials.env's value wherever it has one, so offer that by default only when no profile line could be newer
    clash=""
    for v in $CRED_VARS; do
      [ -n "$(outside_defs "$v")" ] || continue
      fv="$(file_value "$v")"
      [ -n "$fv" ] || continue
      if [ "$FRESH_OK" != 1 ] || [ "$(fresh "$v")" != "$fv" ]; then clash="$clash $v"; fi
    done
    if [ -z "$clash" ]; then
      fix_offer "Move the credential lines in $stray into credentials.env and disable them (backups kept)" Y fix_outside_creds
    elif [ "$FRESH_OK" != 1 ]; then
      note "couldn't start a new terminal to see which value of$clash is in use, so run '$ME credentials' to choose"
      fix_offer "Keep the credentials.env values of$clash, and disable the credential lines in $stray (backups kept)" N fix_outside_creds
    fi
  fi
}

doctor_maven() {
  local f="$HOME/.m2/settings.xml" missing
  section "Maven settings"
  if [ ! -f "$f" ]; then bad "~/.m2/settings.xml is missing (Maven Configuration page)"; fix_offer "Write ~/.m2/settings.xml from the onboarding template" Y fix_maven_settings; return 0; fi
  if settings_xml | cmp -s - "$f"; then ok "~/.m2/settings.xml matches the onboarding template"; return 0; fi
  missing="$(settings_missing "$f")"
  if [ -z "$missing" ]; then ok "~/.m2/settings.xml has the required servers, repositories and profile (customized)"
  else bad "~/.m2/settings.xml is missing: $missing"; fix_offer "Replace ~/.m2/settings.xml with the onboarding template (a backup is kept)" Y fix_maven_settings; fi
}

doctor_runtime() {
  local ctx
  section "Containers and Kubernetes"
  docker_up
  case $? in
    0) ok "Docker engine running (context $(docker context show 2>/dev/null))" ;;
    124) bad "Docker engine isn't answering (docker info took over ${DOCKER_TIMEOUT}s): restart OrbStack (or Docker Desktop)" ;;
    *) bad "Docker engine isn't running: start OrbStack (or Docker Desktop)"; offer_runtime_fix ;;
  esac
  if has orb && { [ "$(docker context show 2>/dev/null)" = orbstack ] || [ "$(current_context)" = orbstack ]; }; then
    if [ "$(orb config get k8s.enable 2>/dev/null)" = true ]; then ok "OrbStack Kubernetes is on"
    else bad "OrbStack Kubernetes is off: orb config set k8s.enable true"; fix_offer "Start OrbStack, with its Kubernetes on" Y start_orbstack; fi
  fi
  ctx="$(current_context)"
  if [ -z "$ctx" ]; then
    bad "kubectl has no current context"
    offer_context_fix "Point kubectl at the local cluster" Y
  elif is_local_context "$ctx"; then
    ok "kubectl context: $ctx"
    if kube_ready "$ctx"; then ok "Kubernetes node is Ready"
    else
      bad "Kubernetes isn't answering (context $ctx)"
      case "$ctx" in
        orbstack) fix_offer "Start OrbStack, with its Kubernetes on" Y start_orbstack ;;
        docker-desktop) fix_offer "Start Docker Desktop and wait for its Kubernetes" Y start_docker_desktop ;;
      esac
    fi
  else
    warn "kubectl context '$ctx' isn't a local cluster, and skaffold deploys to the current context (kubectl config use-context orbstack)"
    offer_context_fix "Switch kubectl from '$ctx' to the local cluster" N
  fi
  if grep -qsF "\"$DTR_HOST\"" "$HOME/.docker/config.json"; then ok "docker is logged in to $DTR_HOST"
  else
    bad "docker isn't logged in to $DTR_HOST (fix with: $ME credentials)"
    if [ -n "${DTR_USER:-}" ] && [ -n "${DTR_PWD:-}" ] && ! is_encrypted "$DTR_PWD"; then fix_offer "Log docker in to $DTR_HOST as DTR_USER" Y docker_login_dtr; fi
  fi
  if run_limited "$NET_TIMEOUT" docker manifest inspect "$DTR_TEST_IMAGE" >/dev/null 2>&1; then ok "can pull images from $DTR_HOST"
  else bad "can't read $DTR_TEST_IMAGE from $DTR_HOST"; fi
}

onboarding_leftovers() { # where the onboarding accounts still appear (Token Verification Checklist, step 7)
  local out="" f n
  if clt_ready && git config --global --list 2>/dev/null | grep -Eq "$ONBOARDING_USERS_REGEX"; then out="$out ~/.gitconfig"; fi
  for f in "$HOME/.m2/settings.xml" "$ZDIR/.zshrc" "$ZDIR/.zprofile" "$HOME/.zshenv" "$HOME/.bashrc" "$HOME/.bash_profile" \
    "$HOME/.netrc" "$HOME/.git-credentials" "$CRED_FILE" "$JIRA_CONFIG"; do
    if [ -f "$f" ] && grep -Eq "$ONBOARDING_USERS_REGEX" "$f" 2>/dev/null; then out="$out $(tildify "$f")"; fi
  done
  n="$(security dump-keychain 2>/dev/null | grep -Ec "\"(acct|svce|srvr)\"<blob>=\"[^\"]*($ONBOARDING_USERS_REGEX)" || true)"
  [ "${n:-0}" -eq 0 ] || out="$out keychain ($n item(s); search Keychain Access for coderepo and va.gov)"
  printf '%s' "${out# }"
}

doctor_credentials() {
  local id u s v rc saved bu leftovers bb_ok=0 enter=""
  section "Credentials (each tried against its server, read-only)"
  for id in bitbucket nexus dtr jira jenkins; do
    cred_meta "$id"
    s="$(cval "$C_SVAR")"
    if [ "$id" = jira ]; then u="$(jira_login)"; else u="$(cval "$C_UVAR")"; fi
    if [ -z "$s" ] && { [ "$id" = jira ] || [ -z "$u" ]; }; then
      case "$id" in jira | jenkins) skip "$C_TITLE: not set up" ;; *) bad "$C_TITLE: $C_UVAR / $C_SVAR not set"; enter="$enter $id" ;; esac
      continue
    fi
    if [ -z "$s" ] || { [ "$id" != jira ] && [ -z "$u" ]; }; then
      if [ -z "$s" ]; then v="$C_SVAR"; else v="$C_UVAR"; fi
      case "$id" in jenkins) warn "$C_TITLE: $v isn't set, so jcli can't log in" ;; *) bad "$C_TITLE: $v isn't set" ;; esac
      enter="$enter $id"
      continue
    fi
    check_credential "$id" "$u" "$s"
    rc=$?
    case $rc in
      0) ok "$C_TITLE: accepted by $CHECK_HOST"; [ "$id" != bitbucket ] || bb_ok=1 ;;
      1) bad "$C_TITLE: rejected by $CHECK_HOST (HTTP $CHECK_CODE); check $C_UVAR / $C_SVAR"; enter="$enter $id" ;;
      3) if [ -f "$HOME/.m2/settings-security.xml" ]; then info "$C_TITLE: $C_SVAR is Maven-encrypted, so only Maven can use it (not checked)"
        else bad "$C_TITLE: $C_SVAR is Maven-encrypted, but ~/.m2/settings-security.xml is missing, so Maven can't decrypt it"; fi ;;
      *) warn "$C_TITLE: couldn't reach $CHECK_HOST (HTTP $CHECK_CODE)" ;;
    esac
  done
  # shellcheck disable=SC2086 # the IDs split into words
  [ -z "$enter" ] || fix_offer "Enter the $(cred_titles $enter) credentials (asks for each)" I fix_credentials $enter
  git_ready || return 0
  bu="$(cval VA_BITBT_USER)"
  saved="$(coderepo_saved)"
  if [ -z "$saved" ]; then
    warn "git has no saved $CODEREPO_HOST login, so it will ask (username ${bu:-<yours>}, password: the token)"
    [ "$bb_ok" = 0 ] || fix_offer "Save VA_BITBT_USER / VA_BITBT_PWD as git's $CODEREPO_HOST login" Y fix_git_login
  elif is_encrypted "$(cval VA_BITBT_PWD)"; then
    if [ "$(cred_field "$saved" username)" = "$bu" ]; then ok "git has its own saved $CODEREPO_HOST login for $bu (VA_BITBT_PWD is Maven-encrypted)"
    else warn "git's saved $CODEREPO_HOST login is for $(cred_field "$saved" username), not $bu"; fi
  elif [ "$(cred_field "$saved" username)" = "$bu" ] && [ "$(cred_field "$saved" password)" = "$(cval VA_BITBT_PWD)" ]; then
    ok "git's saved $CODEREPO_HOST login matches VA_BITBT_USER / VA_BITBT_PWD"
  else
    warn "git's saved $CODEREPO_HOST login (user $(cred_field "$saved" username)) isn't VA_BITBT_USER / VA_BITBT_PWD (fix with: $ME credentials)"
    [ "$bb_ok" = 0 ] || fix_offer "Save VA_BITBT_USER / VA_BITBT_PWD as git's $CODEREPO_HOST login" Y fix_git_login
  fi
  if [ -n "$bu" ] && printf '%s' "$bu" | grep -Eq "^($ONBOARDING_USERS_REGEX)"; then
    info "VA_BITBT_USER ($bu) is an onboarding-team account. That's expected until you have your own tokens; then run: $ME credentials"
  elif [ -n "$bu" ]; then
    leftovers="$(onboarding_leftovers)"
    if [ -z "$leftovers" ]; then ok "no onboarding-team logins left in git, Maven, dotfiles or the keychain"
    else warn "onboarding-team logins still found in: $leftovers"; fi
  fi
}

doctor_cli() {
  local tok me
  section "Jira and Jenkins CLIs"
  if ! has jira; then
    skip "jira-cli isn't installed"
  elif [ ! -f "$JIRA_CONFIG" ]; then
    warn "jira-cli isn't set up yet: re-run install, or run 'jira init' (Jira CLI page)"
    [ -z "${JIRA_API_TOKEN:-}" ] || fix_offer "Set up jira-cli (jira init, project $JIRA_PROJECT)" Y step_jira
  else
    ok "jira-cli set up for $(jira_login)"
    tok="$(cval JIRA_API_TOKEN)"
    if [ -n "$tok" ]; then
      if me="$(JIRA_API_TOKEN="$tok" run_limited "$NET_TIMEOUT" jira me </dev/null 2>/dev/null)" && [ -n "$me" ]; then ok "jira me: $me"; else bad "'jira me' failed; check JIRA_API_TOKEN"; fi
    fi
  fi
  if [ ! -s "$JENKINS_JAR" ]; then
    skip "Jenkins CLI isn't installed"
  elif ! unzip -tqq "$JENKINS_JAR" >/dev/null 2>&1; then
    bad "$(tildify "$JENKINS_JAR") isn't a JAR; it may be a saved sign-in page (fix: $ME install)"
    fix_offer "Download jenkins-cli.jar again" Y step_jenkins
  else
    ok "$(tildify "$JENKINS_JAR")"
    if [ "$FRESH_OK" = 1 ]; then
      if [ "$(fresh JCLI_FUNC)" = yes ]; then ok "jcli helper is defined in new terminals"; else warn "the jcli helper isn't defined in new terminals (Jenkins CLI page)"; offer_block_fix; fi
    fi
  fi
}

doctor_intellij() {
  local app have id present="" missing="" cur old_ifs
  section "IntelliJ IDEA"
  app="$(idea_app)" || { skip "IntelliJ IDEA isn't installed"; return 0; }
  idea_info "$app" || { warn "couldn't read IntelliJ's product-info.json"; return 0; }
  ok "IntelliJ IDEA $IDEA_VERSION"
  have="$(idea_plugin_ids "$IDEA_CONFIG")"
  old_ifs="$IFS"
  IFS='|'
  # shellcheck disable=SC2086
  set -- $IDEA_PLUGINS
  IFS="$old_ifs"
  for id in "$@"; do
    if printf '%s\n' "$have" | grep -qxF -- "$id"; then present="$present, $(plugin_label "$id")"; else missing="$missing, $(plugin_label "$id")"; fi
  done
  [ -z "$present" ] || ok "plugins: ${present#, }"
  [ -z "$missing" ] || { warn "plugins not installed: ${missing#, } (fix: $ME intellij)"; offer_intellij_fix; }
  if [ -f "$IDEA_CONFIG/codestyles/$IDEA_CODE_STYLE.xml" ]; then
    if grep -qs "name=\"CURRENT_SCHEME_NAME\" value=\"$IDEA_CODE_STYLE\"" "$IDEA_CONFIG/options/code.style.schemes.xml"; then ok "code style $IDEA_CODE_STYLE (the default)"
    else warn "code style $IDEA_CODE_STYLE is imported but isn't the default (fix: $ME intellij)"; offer_intellij_fix; fi
  else
    warn "code style $IDEA_CODE_STYLE isn't imported (fix: $ME intellij)"
    offer_intellij_fix
  fi
  if [ ! -f "$IDEA_CONFIG/inspection/$IDEA_INSPECTIONS.xml" ]; then
    warn "inspections $IDEA_INSPECTIONS aren't imported (fix: $ME intellij)"
    offer_intellij_fix
  elif grep -qs "name=\"PROJECT_PROFILE\" value=\"$IDEA_INSPECTIONS\"" "$IDEA_CONFIG/options/project.default.xml"; then
    ok "inspections $IDEA_INSPECTIONS (the default for new projects)"
  else
    ok "inspections $IDEA_INSPECTIONS imported; select them in each project (Settings > Editor > Inspections)"
  fi
  cur="$(sed -n 's/.*name="authenticationProvider" value="\([^"]*\)".*/\1/p' "$IDEA_CONFIG/options/github-copilot.xml" 2>/dev/null | head -n 1)"
  case "$cur" in
    va.ghe.com) ok "GitHub Copilot signs in through va.ghe.com" ;;
    "") skip "GitHub Copilot isn't set up yet (Resources > GitHub Copilot Access)" ;;
    *) warn "GitHub Copilot signs in through $cur, not va.ghe.com (Tools > GitHub Copilot > General)"; offer_intellij_fix ;;
  esac
}

doctor_repos() {
  local name path why dir pins
  local -a need
  section "Repositories in $(tildify "$WORKSPACE_DIR")"
  while IFS='|' read -r name path why; do
    [ -n "$name" ] || continue
    dir="$WORKSPACE_DIR/$name"
    if [ ! -d "$dir/.git" ]; then skip "$name isn't cloned (used for $why)"; continue; fi
    pins="$(missing_pins "$dir")"
    if [ -z "$pins" ]; then ok "$name"; else warn "$name pins $pins, which isn't installed: cd $(tildify "$dir") && sdk env install"; need[${#need[@]}]="$dir"; fi
  done <<EOF
$REPOS
EOF
  [ "${#need[@]}" = 0 ] || fix_offer "Install the JDK and Maven versions the repositories pin" Y install_repo_pins "${need[@]}"
}

doctor_all() {
  N_OK=0 N_WARN=0 N_BAD=0
  load_fresh_env
  doctor_system
  doctor_tools
  doctor_java
  doctor_shell
  doctor_maven
  doctor_runtime
  doctor_credentials
  doctor_cli
  doctor_intellij
  doctor_repos
  printf '\n    %sdoctor: %d ok, %d warning(s), %d problem(s)%s\n' "$B" "$N_OK" "$N_WARN" "$N_BAD" "$RST"
}

# --- Install flow -------------------------------------------------------------------------------
decide_java_default() {
  local cur
  cur="$(sdk_current java)"
  case "$cur" in
    "") SET_JAVA_DEFAULT=1 ;;
    "${JAVA_DEFAULT%%.*}".*) SET_JAVA_DEFAULT=0 ;; # already the docs' major version
    *) if ask_yn "    Your default Java is $cur. Make $JAVA_DEFAULT the default, as the docs do?" Y; then SET_JAVA_DEFAULT=1; else SET_JAVA_DEFAULT=0; fi ;;
  esac
}

ask_questions() {
  local c ids="bitbucket nexus dtr"
  section "A few questions"
  if [ -z "$RUNTIME" ]; then
    if app_present orbstack && app_present docker-desktop; then
      RUNTIME="$(active_runtime)"
      if [ -n "$RUNTIME" ]; then say "OrbStack and Docker Desktop are both installed; keeping $RUNTIME, the one docker uses now"
      elif ask_yn "    OrbStack and Docker Desktop are both installed. Use OrbStack? (No uses Docker Desktop)" Y; then RUNTIME=orbstack
      else RUNTIME=docker-desktop; fi
    elif app_present orbstack; then RUNTIME=orbstack
    elif app_present docker-desktop; then RUNTIME=docker-desktop
    elif ask_yn "    Use OrbStack for containers? (recommended; no picks Docker Desktop)" Y; then RUNTIME=orbstack
    else RUNTIME=docker-desktop; fi
  fi
  say "Container runtime: $RUNTIME"
  if [ "$MINIMAL" = 1 ]; then
    SELECTED=""
  elif ask_yn "    Install the recommended extras? IntelliJ IDEA, Postman, Headlamp, PlantUML, Jira CLI, Jenkins CLI, git hooks, macOS text settings" Y; then
    SELECTED="$RECOMMENDED"
  else
    for c in $RECOMMENDED; do ! ask_yn "      $(component_desc "$c")?" N || SELECTED="$SELECTED $c"; done
  fi
  if [ "$MINIMAL" != 1 ] && [ "$ASSUME_YES" != 1 ] && ask_yn "    Add optional apps? iTerm2, Sublime Text, Windows App, Citrix Workspace" N; then
    for c in $EXTRAS; do ! ask_yn "      $(component_desc "$c")?" N || SELECTED="$SELECTED $c"; done
  fi
  if git_ready; then
    GIT_NAME="$(git config --global --get user.name 2>/dev/null)"
    GIT_EMAIL="$(git config --global --get user.email 2>/dev/null)"
  fi
  [ -n "$GIT_NAME" ] || GIT_NAME="$(id -F 2>/dev/null)"
  GIT_NAME="$(ask "    Your name for git commits" "$GIT_NAME")"
  GIT_EMAIL="$(ask "    Your Apothesource email for git commits" "$GIT_EMAIL")"
  decide_java_default
  if [ "$DO_CLONE" = 1 ] || [ "$DO_BUILD" = 1 ]; then
    WORKSPACE_DIR="$(abs_dir "$(ask "    Folder for repositories" "$(tildify "$WORKSPACE_DIR")")" "$HOME")" # "Projects" means ~/Projects
  fi
  if [ "$DO_CLONE" = 1 ] && ! ask_yn "    Clone the onboarding repositories? $(repo_names)" Y; then DO_CLONE=0; fi
  if [ "$DO_BUILD" = 1 ] && ! ask_yn "    Finish with the location-service build? (5 to 15 minutes)" Y; then DO_BUILD=0; fi
  selected jira && ids="$ids jira"
  selected jenkins && ids="$ids jenkins"
  # shellcheck disable=SC2086
  gather_credentials $ids
}

print_plan() {
  local casks creds
  casks="$(plan_casks)"
  creds="$(cred_summary)"
  section "Plan"
  say "Homebrew tools   $(plan_formulas)"
  [ -z "$casks" ] || say "Homebrew apps    $casks"
  say "SDKMAN           java $JAVA_EXTRA, java $JAVA_DEFAULT$([ "$SET_JAVA_DEFAULT" != 1 ] || printf ' (default)'), maven $MAVEN_VERSION, gradle"
  say "Shell            $(rc_names) block with SKAFFOLD_PUSH, SKAFFOLD_RPC_PORT, DTR_URL$(! selected jenkins || printf ', jcli')"
  say "Credentials      ${creds:-none yet} -> $(tildify "$CRED_FILE"), git's saved login, docker login"
  say "Maven            ~/.m2/settings.xml from the onboarding template"
  say "Git              name, email, core.trustctime=false"
  say "Containers       ${RUNTIME/none/your current runtime} with Kubernetes on"
  ! selected jira || say "Jira CLI         jira init (project $JIRA_PROJECT, board Greenfield)"
  ! selected jenkins || say "Jenkins CLI      ~/.jenkins-cli/jenkins-cli.jar and the jcli helper"
  ! selected githooks || say "Git hooks        ~/.git-hooks: conflict-marker and build-artifact guards, TruffleHog, no direct pushes to main"
  ! selected macos || say "macOS            smart quotes and dashes off, hidden files shown in Finder"
  [ "$DO_CLONE" != 1 ] || say "Repositories     $(repo_names) -> $(tildify "$WORKSPACE_DIR")"
  [ -z "$CLONE_SYSTEMS" ] || say "Team repos       every repository in Backstage system $CLONE_SYSTEMS -> $(tildify "$WORKSPACE_DIR")"
  ! selected intellij || say "IntelliJ IDEA    PlantUML, Cloud Code, Bitbucket and Copilot plugins; VA code style and inspections"
  [ "$DO_BUILD" != 1 ] || say "Final check      mvn clean verify -Pwith-skaffold in location-service"
}

run_step() { # TITLE FUNCTION: records ok / warn / fail and returns 1 on fail
  local rc i
  section "$1"
  NOTE=""
  "$2"
  rc=$?
  i=${#R_NAME[@]}
  R_NAME[i]="$1"
  R_NOTE[i]="$NOTE"
  case $rc in 0) R_STATE[i]=ok ;; 2) R_STATE[i]=warn ;; *) R_STATE[i]=fail; return 1 ;; esac
  return 0
}
print_summary() {
  local i=0 label
  section "Summary"
  while [ "$i" -lt "${#R_NAME[@]}" ]; do
    case "${R_STATE[$i]}" in ok) label="$GRN""ok  $RST" ;; warn) label="$YLW""warn$RST" ;; *) label="$RED""FAIL$RST" ;; esac
    if [ -n "${R_NOTE[$i]}" ]; then printf '    %s  %s %s(%s)%s\n' "$label" "${R_NAME[$i]}" "$DIM" "${R_NOTE[$i]}" "$RST"
    else printf '    %s  %s\n' "$label" "${R_NAME[$i]}"; fi
    i=$((i + 1))
  done
}
summary_failed() {
  local i=0
  while [ "$i" -lt "${#R_STATE[@]}" ]; do [ "${R_STATE[$i]}" != fail ] || return 0; i=$((i + 1)); done
  return 1
}

todo() { # TEXT [DETAIL]
  TODO_N=$((TODO_N + 1))
  printf '    %2d. %s\n' "$TODO_N" "$1"
  [ -z "${2:-}" ] || printf '        %s%s%s\n' "$DIM" "$2" "$RST"
}
print_next_steps() {
  local ws dot
  ws="$(tildify "$WORKSPACE_DIR")"
  dot="$(command -v dot 2>/dev/null)" || dot="$(brew --prefix 2>/dev/null || { arm_mac && echo "$BREW_ARM"; } || echo "$BREW_X86")/bin/dot"
  section "Still to do by hand"
  TODO_N=0
  [ "$RUNTIME" != orbstack ] || todo "OrbStack license: run 'orb login' with your Apothesource email, then ask Jim Leonard or Amy Calvert to assign a license."
  [ "$RUNTIME" != docker-desktop ] || todo "Docker Desktop: turn on Kubernetes; set CPU 8, memory 20 GB or more, swap 4 GB, disk 128 GB or more."
  if selected intellij || app_present intellij-idea; then
    todo "IntelliJ IDEA: get a license from Amy Calvert, then File > Open > $ws/location-service/pom.xml > Open as Project."
    todo "IntelliJ plugins: add your Bitbucket token to Bitbucket Integration Pro, and once you have VA access, sign in to GitHub Copilot" "with the va.ghe.com device code (Tools > GitHub Copilot). If IntelliJ was open during setup, quit it and run: $ME intellij"
    ! { selected plantuml || has plantuml; } || todo "PlantUML: set the Graphviz dot executable to $dot (Settings > Languages and Frameworks > PlantUML)."
  fi
  [ -n "$CLONE_SYSTEMS" ] || todo "Clone your team's repositories: '$ME clone' lists the Backstage systems, then: $ME clone --system <name>"
  todo "Import the developer bookmarks (Resources > Useful Bookmarks)."
  todo "Start the Starter Project in $ws/starter-project on a branch named starter_<first>_<last>."
  todo "When your PIV arrives, create your own Bitbucket, VA Nexus, Sandbox DTR, Jira and Jenkins tokens, then run: $ME credentials" "It replaces the onboarding values everywhere this script put them. Update IntelliJ's saved git login and the Bitbucket plugin yourself."
  todo "VA network access (needs your PIV): Citrix Access Gateway or Azure Virtual Desktop, then GitHub Copilot through va.ghe.com."
}

preflight() {
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$ASSUME_YES" != 1 ] && ! have_tty; then die "No terminal to ask questions on. Run it in Terminal, or pass --yes (with --credentials-file)."; fi
  [ "$(http_code https://github.com)" != 000 ] || warn "github.com isn't reachable; downloads will fail until you're online"
}
arm_mac() { [ "$ARM_MAC" = 1 ]; }
under_rosetta() { [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = 1 ]; } # Intel code translated to run on Apple Silicon
intel_brew_on_arm() { arm_mac && [ -x "$BREW_X86/bin/brew" ]; }
native_restart() { # under Rosetta on Apple Silicon: run this file again as Apple Silicon code; returns 1 if it can't
  [ -z "${NGSS_NATIVE_RESTART:-}" ] || return 1 # restarted once already, and still translated
  [ -n "$SELF" ] || return 1                     # piped into bash, so there's no file to run again
  /usr/bin/arch -arm64 /usr/bin/true 2>/dev/null || return 1
  say "Starting again as Apple Silicon code (arch -arm64), outside Rosetta..."
  exec env NGSS_NATIVE_RESTART=1 /usr/bin/arch -arm64 /bin/bash "$SELF" ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"}
}
free_kb() { # DIR -> KB free on its disk (NGSS_FREE_KB, for the tests, replaces the answer)
  if [ -n "${NGSS_FREE_KB:-}" ]; then printf '%s' "$NGSS_FREE_KB"; else df -Pk "$1" 2>/dev/null | awk 'NR == 2 && $4 ~ /^[0-9]+$/ { print $4 }'; fi
}
va_reachable() { [ "$(http_code "$CODEREPO/status" 8)" != 000 ]; } # any answer at all means the VA network is there
stop_here() { say "Stopped before changing anything."; exit 1; }
notify_done() { # START MESSAGE: a macOS notification, if the run took long enough that you may have switched to something else
  [ "$DRY_RUN" != 1 ] && [ $(($(date +%s) - $1)) -ge "$NOTIFY_AFTER" ] || return 0
  # The text goes in as arguments, never as AppleScript source
  run_limited 10 osascript -e 'on run argv' -e 'display notification (item 2 of argv) with title (item 1 of argv)' -e 'end run' "$ME" "$2" >/dev/null 2>&1
  return 0
}

install_checks() { # Rosetta, an Intel Homebrew left by Migration Assistant, disk space and the VA network, before any questions
  local kb
  section "This Mac"
  if under_rosetta; then
    warn "this terminal runs under Rosetta (Intel emulation), where Homebrew won't install Apple Silicon tools, and SDKMAN would pick Intel JDKs"
    note "So new terminals don't use Rosetta: quit Terminal, select it in Finder > Applications > Utilities, choose File > Get Info, and turn off Open using Rosetta."
    [ "$DRY_RUN" = 1 ] || native_restart || stop_here
  elif arm_mac; then
    ok "Apple Silicon"
  else
    ok "Intel Mac"
    note "Homebrew stops supporting Intel Macs in September 2027; after that, install its tools yourself: ${DOCS_URL%/*}/intel-macs.html"
  fi
  if intel_brew_on_arm; then
    warn "an Intel copy of Homebrew is in $(tildify "$BREW_X86"), probably copied by Migration Assistant; its tools need Rosetta (Intel emulation) to run, and build from source"
    note "This setup uses Homebrew in $(tildify "$BREW_ARM") instead, installing it if needed, and puts it first in new terminals. Remove the Intel copy when you no longer need its tools: https://github.com/Homebrew/install#uninstall-homebrew"
  fi
  kb="$(free_kb "$HOME")"
  if [ -z "$kb" ]; then
    skip "couldn't tell how much disk space is free"
  elif [ "$kb" -lt "$NEED_KB" ]; then
    warn "only $(human_kb "$kb") free; the setup needs about 30 GB for tools, JDKs, IntelliJ and container images"
    note "'$ME tidy' shows what it can remove."
    if [ "$DRY_RUN" != 1 ]; then
      if [ "$kb" -lt "$LOW_KB" ]; then ask_yn "    Continue anyway? It will probably run out of space." N || stop_here
      else ask_yn "    Continue anyway?" Y || stop_here; fi
    fi
  else
    ok "$(human_kb "$kb") free"
  fi
  if va_reachable; then
    ok "$CODEREPO_HOST is reachable (VA network)"
  else
    warn "$CODEREPO_HOST isn't reachable; if you're off-network, connect to the VPN"
    note "Without it, cloning, the Bitbucket and VA Nexus token checks and the Maven build fail; run this again once you're connected to finish them."
    [ "$DRY_RUN" = 1 ] || ask_yn "    Continue without the VA network?" Y || stop_here
  fi
}

script_traces() { # 0 if this script has set up this Mac before: its profile block, or ~/.config/ngss
  local f
  [ ! -d "$NGSS_DIR" ] || return 0
  while IFS= read -r f <&3; do
    [ -n "$f" ] && grep -qxF -e "$MARK_BEGIN" -e "$MARK_END" "$f" 2>/dev/null && return 0
  done 3<<EOF
$(profile_files)
EOF
  return 1
}
manual_setup() { # -> one line per NGSS setup step done by hand, for a Mac this script hasn't set up (clones alone don't count)
  local defs
  script_traces && return 0
  defs="$(outside_defs "$CRED_VARS SKAFFOLD_PUSH SKAFFOLD_RPC_PORT DTR_URL")"
  [ -z "$defs" ] || echo "shell settings or credentials from the docs, in $defs"
  ! grep -qE 'mobilehealth\.va\.gov|ckm-releases' "$HOME/.m2/settings.xml" 2>/dev/null || echo "~/.m2/settings.xml for VA Nexus (Maven Configuration page)"
  ! grep -qF "${JIRA_SERVER#https://}" "$JIRA_CONFIG" 2>/dev/null || echo "the Jira CLI, set up for ${JIRA_SERVER#https://}"
  [ ! -f "$JENKINS_JAR" ] || echo "the Jenkins CLI in $(tildify "${JENKINS_JAR%/*}")"
  [ ! -e "$HOOKS_DIR/dispatch" ] || echo "the Modular Git Hooks in $(tildify "$HOOKS_DIR")"
}
manual_setup_check() { # stop install on a Mac set up partly by hand, where its output would mix with what's there
  local found line
  [ "$DO_FORCE" != 1 ] || return 0
  found="$(manual_setup)"
  [ -n "$found" ] || return 0
  section "Setup already started by hand"
  say "This Mac already has parts of the NGSS setup that this script didn't make:"
  while IFS= read -r line; do say "  - $line"; done <<EOF
$found
EOF
  echo
  say "The guided setup would work around these, back up and rewrite some of them, and its output would"
  say "be hard to follow. Instead:"
  say "  1. Finish the setup by hand: ${DOCS_URL%/*}/dev-env-setup.html"
  say "     (and the token, Maven and Skaffold pages it links to)."
  say "  2. Check it:                 bash $ME doctor"
  say "  3. Repair what it finds:     bash $ME doctor --fix   (asks before each fix)"
  echo
  say "To run the guided setup anyway: bash $ME install --force"
  exit 1
}

banner() {
  printf '%s%s %s%s: %s\n' "$B" "$ME" "$VERSION" "$RST" "$1"
  [ "$DRY_RUN" != 1 ] || printf '%sDry run: nothing will be changed.%s\n' "$YLW" "$RST"
}

cmd_install() {
  local start
  banner "set up a VA Mobile / NGSS developer Mac"
  preflight
  manual_setup_check
  check_latest
  setup_path
  install_checks
  load_credentials
  [ -z "$CRED_IMPORT_FILE" ] || import_credentials "$CRED_IMPORT_FILE"
  ask_questions
  print_plan
  ask_yn "Start?" Y || exit 0
  save_settings
  start=$(date +%s)
  run_step "Xcode Command Line Tools" step_clt || { print_summary; exit 1; }
  run_step "Homebrew" step_homebrew || { print_summary; exit 1; }
  run_step "Homebrew tools and apps" step_brew_packages
  kick_runtime
  run_step "Git" step_git
  run_step "SDKMAN" step_sdkman
  run_step "Java, Maven and Gradle" step_java
  run_step "Shell environment and credentials" step_shell
  run_step "Maven settings" step_maven_settings
  run_step "Containers and Kubernetes" step_runtime
  ! selected jira || run_step "Jira CLI" step_jira
  ! selected jenkins || run_step "Jenkins CLI" step_jenkins
  ! selected githooks || run_step "Git hooks" step_githooks
  ! selected macos || run_step "macOS text settings" step_macos
  [ "$DO_CLONE" != 1 ] || run_step "Repositories" step_repos
  [ -z "$CLONE_SYSTEMS" ] || run_step "Team repositories ($CLONE_SYSTEMS)" step_team_repos
  ! selected intellij || run_step "IntelliJ IDEA settings" step_intellij
  [ "$DO_BUILD" != 1 ] || run_step "location-service build" step_build
  print_summary
  say "Finished in $(elapsed "$start")."
  [ "$DRY_RUN" = 1 ] || doctor_all
  print_next_steps
  if summary_failed; then notify_done "$start" "Setup finished with problems; see Terminal."
  else notify_done "$start" "Setup finished. Terminal lists what's left to do by hand."; fi
  ! summary_failed
}

cmd_doctor() {
  if [ "$DO_FIX" = 1 ]; then banner "doctor: check, then offer to fix what it finds"; else banner "doctor (read-only)"; fi
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$DO_FIX" = 1 ] && [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes or --dry-run."; fi
  setup_path
  load_credentials
  doctor_all
  [ "$DO_FIX" != 1 ] || doctor_fix
  [ "$N_BAD" -eq 0 ]
}

save_credentials() { # credentials.env, the shell profile, git's saved login, docker login and jira-cli; 1 if one failed, 3 if nothing was saved
  local f rc=0
  [ -n "$(cred_summary)" ] || { warn "nothing to save"; return 3; }
  while IFS= read -r f <&3; do write_block "$f" || rc=1; done 3<<EOF
$(rc_files)
EOF
  write_credentials || return 3
  disable_outside_creds
  [ $? -ne 1 ] || rc=1
  if git_ready && [ -n "${VA_BITBT_USER:-}" ] && [ -n "${VA_BITBT_PWD:-}" ]; then
    git_save_coderepo "$VA_BITBT_USER" "$VA_BITBT_PWD"
    [ $? -ne 1 ] || rc=1
  fi
  if has docker && docker_up; then
    docker_login_dtr
    [ $? -ne 1 ] || rc=1
  else
    skip "Docker isn't running; run this again later to refresh docker login"
  fi
  if has jira && [ -n "${JIRA_API_TOKEN:-}" ]; then
    step_jira
    [ $? -ne 1 ] || rc=1
  fi
  return $rc
}

cmd_credentials() {
  local rc=0 r
  banner "enter or replace credentials"
  preflight
  setup_path
  load_credentials
  [ -z "$CRED_IMPORT_FILE" ] || import_credentials "$CRED_IMPORT_FILE"
  gather_credentials bitbucket nexus dtr jira jenkins
  section "Saving"
  save_credentials
  r=$?
  [ "$r" != 3 ] || return 1
  [ "$r" = 0 ] || rc=1
  maybe_delete_import_file
  if [ "$DRY_RUN" != 1 ]; then
    N_OK=0 N_WARN=0 N_BAD=0
    load_fresh_env
    doctor_shell
    doctor_credentials
    [ "$N_BAD" -eq 0 ] || rc=1
  fi
  section "Next"
  say "Close and reopen your terminals and IntelliJ so they pick up the new values."
  say "If you use them, update the token in IntelliJ's saved git login and in the Bitbucket plugin."
  return $rc
}

cmd_clone() {
  banner "clone a team's repositories"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  setup_path
  clt_ready || die "This needs the Xcode Command Line Tools for git and python3: run $ME install (or xcode-select --install)."
  if [ -z "$CLONE_SYSTEMS" ]; then list_systems; return; fi
  if [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes."; fi
  git_ready || die "git doesn't work yet; install the Command Line Tools first (xcode-select --install)."
  run_step "Repositories in $CLONE_SYSTEMS" step_team_repos
  ! summary_failed
}

cmd_intellij() {
  banner "set up IntelliJ IDEA"
  [ "$(uname -s)" = Darwin ] || die "This script is for macOS."
  if [ "$ASSUME_YES" != 1 ] && [ "$DRY_RUN" != 1 ] && ! have_tty; then die "No terminal to ask questions on; pass --yes."; fi
  setup_path
  run_step "IntelliJ IDEA" step_intellij
  ! summary_failed
}

usage() {
  cat <<EOF
$ME $VERSION: set up and check a VA Mobile / NGSS developer Mac.

Usage: $ME [command] [options]

Commands:
  install        Guided setup (the default). Safe to re-run; finished steps are skipped. It first checks
                 for Rosetta, an Intel Homebrew on Apple Silicon, disk space and the VA network. If you
                 started the setup by hand, it stops and points you to doctor instead (see --force).
  doctor         Read-only check of tools, shell environment, credentials and the local cluster.
                 With --fix it then offers to repair each problem it found, asking first, and can ask
                 you for credentials that are missing or were rejected.
  credentials    Enter or replace credentials, for example to swap the onboarding tokens for your
                 own. Updates credentials.env, git's saved login, docker login and jira-cli.
  clone          Clone every repository in a Backstage system (--system ckm) and install the JDKs
                 they pin. Without --system it lists the systems.
  repos          Branch, uncommitted changes and commits behind or ahead for every repository in the
                 workspace, after a fetch. With --pull it fast-forwards the ones with no changes; with
                 --clean-branches it deletes merged branches whose remote branch is gone.
  intellij       Install the IntelliJ plugins the docs use (PlantUML, Cloud Code, Bitbucket
                 Integration Pro, GitHub Copilot), import the VA code style and inspections, and
                 point Copilot at va.ghe.com. Quit IntelliJ first.
  tidy           Show the space taken by old IntelliJ versions, unused Docker data, SDKMAN versions
                 no repo pins, old Maven SNAPSHOTs and Homebrew's cache; remove what you choose.
  forget         Remove saved credentials: credentials.env, git's saved login, docker login, the
                 Jira CLI settings, and credential lines in your shell profile. Then shows where to
                 revoke each token, because deleting copies doesn't stop a token from working. It
                 asks first (default no), and lists ~/.m2/settings.xml and its backups when they have
                 passwords written into them, for you to fix, without changing them.
  update         Replace this script with the latest version from the onboarding site.
  upgrade        Upgrade Homebrew and the NGSS tools in it, SDKMAN itself, and the Jenkins CLI. Java
                 and Maven stay at the versions your repositories pin. Running apps, apps that update
                 themselves, and new major versions of skaffold, kustomize and trufflehog are skipped.

Options:
  -y, --yes                  Accept the default answer to every question (forget: remove without asking).
      --credentials-file F   Read credentials from a KEY=VALUE file (see below).
      --runtime R            orbstack, docker-desktop, or none (keep what you have).
      --workspace DIR        Where repositories are cloned, and where repos looks. Install remembers the folder you
                             choose (default ~/Projects) and the other commands use it; this changes it for one run.
      --system S             Backstage system(s) to clone, comma-separated (clone or install).
      --minimal              Required tools only: no IDE, CLIs, git hooks or extras.
      --no-clone             Don't clone repositories.
      --no-build             Skip the location-service build at the end.
      --force                install: run even though parts of the setup were done by hand.
      --fix                  doctor: offer to repair each problem found.
      --pull                 repos: fast-forward each repository that has no changes and is only behind.
      --no-fetch             repos: don't fetch; show what each repository knew at its last fetch.
      --clean-branches       repos: delete merged branches whose remote branch is gone (asks first).
      --dry-run              Show what would change and change nothing.
  -h, --help                 Show this help.

What install changes: Homebrew tools and apps (on Apple Silicon, plus Homebrew's line in ~/.zprofile, or in
~/.bash_profile if bash is your shell); SDKMAN with Java $JAVA_EXTRA and $JAVA_DEFAULT, Maven $MAVEN_VERSION and Gradle;
a marked block in ~/.zshrc (and in ~/.bash_profile if bash is your shell); ~/.config/ngss/credentials.env (chmod 600);
~/.m2/settings.xml; git global config and git's saved Coderepo login; docker login for $DTR_HOST;
and, if chosen, ~/.jenkins-cli, ~/.config/.jira, ~/.git-hooks, IntelliJ plugins and settings, and
three macOS settings (smart quotes and dashes off, hidden files shown in Finder). Files it rewrites
are backed up first as <file>.ngss-backup-<time>, and an incomplete ~/.sdkman is moved aside the same way.

Credentials file for --credentials-file: one KEY=VALUE per line, any subset of
  VA_BITBT_USER, VA_BITBT_PWD             Bitbucket username and HTTP access token
  VA_NEXUS_USER, VA_NEXUS_PWD             VA Nexus User Token name and passcode
  DTR_USER, DTR_PWD                       Sandbox DTR User Token name and passcode
  JIRA_LOGIN, JIRA_API_TOKEN              Jira username and personal access token
  JENKINS_API_USER, JENKINS_API_TOKEN     Sandbox Jenkins username and API token

Examples:
  $ME                                            guided setup
  $ME --credentials-file ~/Downloads/onboarding.env
  $ME doctor                                     is anything broken?
  $ME doctor --fix                               ...and repair it, asking before each fix
  $ME credentials                                switch to your own tokens
  $ME clone --system ckm                         clone every CKM repository
  $ME repos --pull                               update the repositories you haven't changed
  $ME repos --clean-branches --dry-run           which merged branches could go?
  $ME intellij                                   plugins and code style for IntelliJ
  $ME tidy --dry-run                             how much disk could be freed?
  $ME upgrade --dry-run                          which tools have upgrades?

Docs and the latest version: $DOCS_URL
EOF
}

need_value() { case "$2" in "" | -*) die "$1 needs a value (see --help)" ;; esac; } # OPTION VALUE: not missing, and not the next option
parse_args() {
  while [ $# -gt 0 ]; do
    case "$1" in
      install | doctor | credentials | clone | intellij | tidy | forget | update | upgrade | repos) COMMAND="$1" ;;
      --fix) DO_FIX=1 ;;
      --pull) DO_PULL=1 ;;
      --no-fetch) DO_FETCH=0 ;;
      --clean-branches) DO_CLEAN=1 ;;
      -y | --yes) ASSUME_YES=1 ;;
      --credentials-file) need_value "$1" "${2:-}"; CRED_IMPORT_FILE="$(untildify "$2")"; shift ;;
      --credentials-file=*) need_value --credentials-file "${1#*=}"; CRED_IMPORT_FILE="$(untildify "${1#*=}")" ;;
      --runtime) need_value "$1" "${2:-}"; RUNTIME="$2"; shift ;;
      --runtime=*) need_value --runtime "${1#*=}"; RUNTIME="${1#*=}" ;;
      --workspace) need_value "$1" "${2:-}"; WORKSPACE_DIR="$(abs_dir "$2" "$PWD")"; shift ;;
      --workspace=*) need_value --workspace "${1#*=}"; WORKSPACE_DIR="$(abs_dir "${1#*=}" "$PWD")" ;;
      --system) need_value "$1" "${2:-}"; CLONE_SYSTEMS="$2"; shift ;;
      --system=*) need_value --system "${1#*=}"; CLONE_SYSTEMS="${1#*=}" ;;
      --minimal) MINIMAL=1 ;;
      --no-clone) DO_CLONE=0 ;;
      --no-build) DO_BUILD=0 ;;
      --force) DO_FORCE=1 ;;
      --dry-run) DRY_RUN=1 ;;
      -h | --help | help) usage; exit 0 ;;
      *) die "Unknown option: $1 (see --help)" ;;
    esac
    shift
  done
  case "$RUNTIME" in "" | orbstack | docker-desktop | none) ;; *) die "--runtime must be orbstack, docker-desktop or none" ;; esac
  if [ -n "$CLONE_SYSTEMS" ]; then # each name starts with a letter or digit, so none reads as an option
    case ",$CLONE_SYSTEMS," in *[!A-Za-z0-9_,.-]* | *,,* | *,[!A-Za-z0-9]*) die "--system takes Backstage system names, such as ckm or ckm,ces" ;; esac
  fi
  [ "$DO_FORCE" = 0 ] || [ "$COMMAND" = install ] || die "--force goes with install: $ME install --force"
  [ "$DO_FIX" = 0 ] || [ "$COMMAND" = doctor ] || die "--fix goes with doctor: $ME doctor --fix"
  [ "$DO_PULL" = 0 ] || [ "$COMMAND" = repos ] || die "--pull goes with repos: $ME repos --pull"
  [ "$DO_FETCH" = 1 ] || [ "$COMMAND" = repos ] || die "--no-fetch goes with repos: $ME repos --no-fetch"
  [ "$DO_CLEAN" = 0 ] || [ "$COMMAND" = repos ] || die "--clean-branches goes with repos: $ME repos --clean-branches"
}

# --- Embedded files (copied from the onboarding site; keep them in sync) ----------------------
# resources/maven-config.adoc
settings_xml() {
  cat <<'NGSS_EOF'
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
                  http://maven.apache.org/xsd/settings-1.0.0.xsd">
  <pluginGroups>
    <pluginGroup>gov.va.mobile.tools</pluginGroup>
    <pluginGroup>gov.va.vamf.tool</pluginGroup>
    <pluginGroup>com.google.cloud.tools</pluginGroup>
  </pluginGroups>
  <servers>
    <server>
      <id>dtr.mapsandbox.net</id>
      <username>${env.DTR_USER}</username>
      <password>${env.DTR_PWD}</password>
    </server>
    <server>
      <id>ckm-releases</id>
      <username>${env.VA_NEXUS_USER}</username>
      <password>${env.VA_NEXUS_PWD}</password>
    </server>
    <server>
      <id>ckm-snapshots</id>
      <username>${env.VA_NEXUS_USER}</username>
      <password>${env.VA_NEXUS_PWD}</password>
    </server>
    <server>
      <id>bitbucket</id>
      <username>${env.VA_BITBT_USER}</username>
      <password>${env.VA_BITBT_PWD}</password>
    </server>
    <server>
      <id>opensaml</id>
      <username>${env.VA_NEXUS_USER}</username>
      <password>${env.VA_NEXUS_PWD}</password>
    </server>
  </servers>
  <activeProfiles>
    <activeProfile>va-mobile</activeProfile>
  </activeProfiles>
  <profiles>
    <profile>
      <id>sonar</id>
      <activation>
        <activeByDefault>false</activeByDefault>
      </activation>
      <properties>
        <sonar.host.url>https://sonarqube.mapsandbox.net/sonarqube</sonar.host.url>
        <sonar.login>${env.DTR_USER}</sonar.login>
        <sonar.password>${env.DTR_PWD}</sonar.password>
      </properties>
    </profile>
    <profile>
      <id>va-mobile</id>
      <activation>
        <activeByDefault>true</activeByDefault>
      </activation>
      <repositories>
        <repository>
          <id>ckm-releases</id>
          <url>https://nexus.mobilehealth.va.gov/content/repositories/ckm-releases/</url>
          <snapshots>
            <enabled>false</enabled>
          </snapshots>
        </repository>
        <repository>
          <id>ckm-snapshots</id>
          <url>https://nexus.mobilehealth.va.gov/content/repositories/ckm-snapshots/</url>
          <snapshots>
            <enabled>true</enabled>
          </snapshots>
        </repository>
        <repository>
          <id>maven-central</id>
          <url>https://repo1.maven.org/maven2</url>
        </repository>
        <repository>
          <id>opensaml</id>
          <url>https://nexus.mobilehealth.va.gov/content/repositories/opensaml/</url>
        </repository>
      </repositories>
      <pluginRepositories>
        <pluginRepository>
            <id>ckm-releases</id>
            <url>https://nexus.mobilehealth.va.gov/content/repositories/ckm-releases/</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
            <releases>
                <enabled>true</enabled>
            </releases>
        </pluginRepository>
        <pluginRepository>
            <id>ckm-snapshots</id>
            <url>https://nexus.mobilehealth.va.gov/content/repositories/ckm-snapshots/</url>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
            <releases>
                <enabled>false</enabled>
            </releases>
        </pluginRepository>
      </pluginRepositories>
    </profile>
  </profiles>
</settings>
NGSS_EOF
}

# resources/git-hooks.adoc
hook_dispatch() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
#
# Generic Git hook dispatcher. Runs every executable file in <hook>.d/ in
# filename order, then the repository's own hook of the same name, if it has
# one: a global core.hooksPath otherwise hides .git/hooks (Git LFS's pre-push,
# for example). Aggregates exit codes: if any of them fails, the hook fails.
#
set -euo pipefail

hook="$(basename "$0")"
base="$(cd "$(dirname "$0")" && pwd)"
hookdir="${base}/${hook}.d"

# Some events deliver data on stdin (e.g. pre-push sends the refs being pushed).
# Save it to a file once, so each hook reads all of it, whether or not the
# hook before it read its input (a pipe would fail when one doesn't).
input=/dev/null
case "$hook" in
  pre-push|pre-receive|update|post-receive|post-rewrite)
    input="$(mktemp "${TMPDIR:-/tmp}/git-hook-input.XXXXXX")"
    trap 'rm -f "$input"' EXIT
    cat >"$input" || true ;;
esac

rc=0
if [ -d "$hookdir" ]; then
  for module in "$hookdir"/*; do
    [ -f "$module" ] && [ -x "$module" ] || continue
    if ! "$module" "$@" <"$input"; then
      echo "✖ hook failed: ${hook}.d/$(basename "$module")" >&2
      rc=1
    fi
  done
fi

# The repository's own hook, unless it is this dispatcher.
gitdir="$(git rev-parse --git-common-dir 2>/dev/null || true)"
own="${gitdir:+$gitdir/hooks/$hook}"
if [ -n "$own" ] && [ -f "$own" ] && [ -x "$own" ] && ! [ "$own" -ef "$0" ]; then
  if ! "$own" "$@" <"$input"; then
    echo "✖ hook failed: $own" >&2
    rc=1
  fi
fi

exit $rc
NGSS_EOF
}
hook_merge_conflict_guard() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
# Reject staged changes that add Git conflict markers.
set -euo pipefail

# Staged files (Added/Copied/Modified), NUL-safe for paths with spaces.
# --no-renames lists a renamed file under its new name instead of leaving it out.
files=()
while IFS= read -r -d '' f; do files+=("$f"); done \
  < <(git diff --cached --name-only --no-renames --diff-filter=ACM -z)
[ ${#files[@]} -eq 0 ] && exit 0

# Look at added lines only ('^+') for the unambiguous markers.
# (======= is intentionally omitted to avoid Markdown/asciidoc false positives.)
hits="$(git diff --cached --no-renames -U0 --no-color -- "${files[@]}" \
        | grep -nE '^\+(<<<<<<<|>>>>>>>)' || true)"

if [ -n "$hits" ]; then
  echo "❌ Merge conflict markers found in staged changes:" >&2
  echo "$hits" >&2
  echo "   Resolve the conflict before committing (or 'git commit --no-verify' to bypass)." >&2
  exit 1
fi
exit 0
NGSS_EOF
}
hook_build_artifact_guard() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
# Reject staged build artifacts and log files.
set -euo pipefail

# Maven's target/ output and any .log file (skaffold.log, verify.log, ...), but not a
# source package that happens to be named target (src/main/java/.../target/).
# --no-renames lists a renamed file under its new name instead of leaving it out.
blocked="$(git diff --cached --name-only --no-renames --diff-filter=ACM \
           | grep -E '(^|/)target/|\.log$' | grep -vE '(^|/)src/(.*/)?target/' || true)"

if [ -n "$blocked" ]; then
  echo "❌ Refusing to commit build artifacts / logs:" >&2
  echo "$blocked" | sed 's/^/   - /' >&2
  echo "   Unstage them ('git restore --staged <file>') and add them to .gitignore." >&2
  echo "   To bypass for one commit: git commit --no-verify" >&2
  exit 1
fi
exit 0
NGSS_EOF
}
hook_trufflehog_staged_scan() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
# Scan staged changes for secrets with TruffleHog.
set -euo pipefail
# Git apps started from the Dock (Sourcetree, GitHub Desktop, Fork, ...) don't have
# Homebrew's folders on PATH, so look there too.
PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"

# If TruffleHog isn't installed, warn but don't block the commit.
if ! command -v trufflehog >/dev/null 2>&1; then
  echo "warning: trufflehog not found on PATH; skipping secret scan." >&2
  echo "         install it with 'brew install trufflehog' to enable scanning." >&2
  exit 0
fi

# Collect staged files that are Added, Copied, or Modified (skip deletions).
# --no-renames lists a renamed file under its new name instead of leaving it out.
# Uses NUL separators to handle paths with spaces.
staged_files=()
while IFS= read -r -d '' file; do
  staged_files+=("$file")
done < <(git diff --cached --name-only --no-renames --diff-filter=ACM -z)

# Nothing to scan (e.g. a commit with only deletions).
[ ${#staged_files[@]} -eq 0 ] && exit 0

# Scan what will be committed: the staged version of each file, which differs
# from the one on disk after 'git add -p' or an edit made after 'git add'.
staged="$(mktemp -d "${TMPDIR:-/tmp}/staged-scan.XXXXXX")"
trap 'rm -rf "$staged"' EXIT
printf '%s\0' "${staged_files[@]}" | git checkout-index -z --stdin --prefix="$staged/"

echo "🔍 Scanning ${#staged_files[@]} staged file(s) for secrets with TruffleHog..."

# --fail returns exit code 183 if any results are found; any other non-zero code
# means the scan itself failed. All results are reported, including unverified
# ones: secrets TruffleHog can't check with their provider, such as private keys
# and passwords, are unverified. The pre-push scan reports fewer, since it covers
# whole branches, where old false positives would get in the way.
status=0
# The folder, not a list of files: thousands of staged paths would be too long for a
# command line, and a path starting with "-" would read as an option.
(cd "$staged" && trufflehog filesystem . \
      --results=verified,unverified,unknown \
      --fail \
      --no-update) || status=$?
if [ "$status" -eq 183 ]; then
  echo
  echo "❌ Potential secret(s) detected in your staged changes (see above)." >&2
  echo "   - Remove the secret and use a vault/env var instead, or" >&2
  echo "   - If it is a false positive, add a 'trufflehog:ignore' comment on that line, or" >&2
  echo "   - To bypass for a single commit (use sparingly): git commit --no-verify" >&2
  exit 1
elif [ "$status" -ne 0 ]; then
  echo "❌ The secret scan didn't finish (trufflehog exit code $status; see above), so the commit was stopped." >&2
  echo "   Fix the problem, or bypass for a single commit (use sparingly): git commit --no-verify" >&2
  exit 1
fi

echo "✅ No secrets detected in staged changes."
exit 0
NGSS_EOF
}
hook_protected_branch_guard() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
# Block direct pushes to protected branches; open a PR instead.
set -euo pipefail

protected='^refs/heads/(main|master|Release/.*)$'

while read -r local_ref local_sha remote_ref remote_sha; do
  [ -z "${remote_ref:-}" ] && continue
  if [[ "$remote_ref" =~ $protected ]]; then
    echo "❌ Direct pushes to '${remote_ref#refs/heads/}' are not allowed." >&2
    echo "   Push a feature branch (e.g. CKM-12345) and open a pull request." >&2
    echo "   To bypass (rarely needed): git push --no-verify" >&2
    exit 1
  fi
done
exit 0
NGSS_EOF
}
hook_trufflehog_full_scan() {
  cat <<'NGSS_EOF'
#!/usr/bin/env bash
# Scan outgoing commits for secrets with TruffleHog before pushing.
set -euo pipefail
# Git apps started from the Dock (Sourcetree, GitHub Desktop, Fork, ...) don't have
# Homebrew's folders on PATH, so look there too.
PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"

if ! command -v trufflehog >/dev/null 2>&1; then
  echo "warning: trufflehog not found; skipping outgoing secret scan." >&2
  exit 0
fi

# Git sends one line per ref being pushed: <local ref> <local commit> <remote ref> <remote commit>.
# Each commit is scanned from where the remote already has it, or from where it left the remote's
# default branch, so only what this push sends is scanned, not your other branches. With nothing to
# compare against, such as the first push of a new repository, its whole history is scanned.
remote="${1:-origin}"
default="$(git symbolic-ref -q --short "refs/remotes/${remote}/HEAD" 2>/dev/null || true)"
if [ -z "$default" ] && git rev-parse --verify -q "${remote}/main" >/dev/null 2>&1; then
  default="${remote}/main"
fi
repo_root="$(git rev-parse --show-toplevel)"

found=0
failed=0
while read -r local_ref local_sha remote_ref remote_sha; do
  # A deleted branch sends nothing, and a tag is scanned as the commit it points to.
  commit="$(git rev-parse -q --verify "${local_sha:-0}^{commit}" 2>/dev/null || true)"
  case "${local_sha:-0}" in *[!0]*) [ -n "$commit" ] || continue ;; *) continue ;; esac
  base=""
  case "${remote_sha:-0}" in
    *[!0]*) base="$(git rev-parse -q --verify "${remote_sha}^{commit}" 2>/dev/null || true)" ;;
  esac
  if [ -z "$base" ] && [ -n "$default" ]; then
    base="$(git merge-base "$commit" "$default" 2>/dev/null || true)"
  fi
  [ "$base" != "$commit" ] || continue # nothing new to send
  name="${local_ref#refs/heads/}"
  # TruffleHog scans its own clone of the repository, where branch names point elsewhere,
  # so it gets commit IDs.
  status=0
  if [ -n "$base" ]; then
    echo "🔍 Scanning the commits ${name} adds for secrets..."
    trufflehog git "file://${repo_root}" --branch "$commit" --since-commit "$base" \
      --results=verified,unknown --fail --no-update || status=$?
  else
    echo "🔍 Scanning all of ${name}'s history for secrets (${remote} has nothing to compare it with)..."
    trufflehog git "file://${repo_root}" --branch "$commit" \
      --results=verified,unknown --fail --no-update || status=$?
  fi
  # --fail returns exit code 183 if any results are found; any other non-zero code
  # means the scan itself failed.
  if [ "$status" -eq 183 ]; then
    found=1
  elif [ "$status" -ne 0 ]; then
    failed="$status"
  fi
done

if [ "$found" -eq 1 ]; then
  echo "❌ Potential secret(s) detected in outgoing commits (see above)." >&2
  echo "   Remove the secret (and rewrite history if already committed) before pushing." >&2
  echo "   To bypass (use sparingly): git push --no-verify" >&2
  exit 1
elif [ "$failed" -ne 0 ]; then
  echo "❌ The secret scan didn't finish (trufflehog exit code $failed; see above), so the push was stopped." >&2
  echo "   Fix the problem, or bypass (use sparingly): git push --no-verify" >&2
  exit 1
fi
echo "✅ No secrets detected in outgoing commits."
exit 0
NGSS_EOF
}

main() {
  ORIG_ARGS=("$@")
  [ -e "$HOME/.bash_profile" ] || BASH_PROFILE_NEW=1
  ZDIR="$(find_zdir)"
  [ ! -f "$0" ] || case "$0" in /*) SELF="$0" ;; *) SELF="$PWD/$0" ;; esac
  load_settings
  parse_args "$@"
  # As root, every command would write root-owned files into your home folder, or root's, and Homebrew refuses to run
  [ "$(id -u)" -ne 0 ] || die "Don't run this with sudo; run it as yourself, and it asks for your password when it needs it."
  [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" != 1 ] || ARM_MAC=1
  trap 'stty echo <"$TTY" 2>/dev/null; [ -z "$BG_PID" ] || stop_tree "$BG_PID"; printf "\nInterrupted. Run %s again to pick up where you left off.\n" "$ME"; exit 130' INT
  case "$COMMAND" in
    install) cmd_install ;;
    doctor) cmd_doctor ;;
    credentials) cmd_credentials ;;
    clone) cmd_clone ;;
    intellij) cmd_intellij ;;
    tidy) cmd_tidy ;;
    forget) cmd_forget ;;
    update) cmd_update ;;
    upgrade) cmd_upgrade ;;
    repos) cmd_repos ;;
  esac
}

main "$@"
