#!/usr/bin/env bash
#
# Minions Installer (macOS and Linux)
#
# Download this file from the Minions page, then run it from the download folder:
#   bash ./install-minions.sh
#
# Installs the newest published version by default. To install one specific
# known-good version instead, pin it -- the option wins over the environment:
#   bash ./install-minions.sh --version 1.2.3
#   MINIONS_INSTALL_VERSION=1.2.3 bash ./install-minions.sh
#
# Every run prints its own launcher revision as its first line. Quote that line
# in a bug report: it identifies exactly which copy of this file you ran.
#
# Requires: Node.js 22.5+, Git, the Azure CLI, and Azure Artifacts *read*
# permission on the internal ISS npm feed named below.
#
# Signing in is NOT a separate step you run first. An Azure CLI session you
# already have is used silently; if you have none, this installer signs you in
# once itself -- but only when it is running in a terminal that can answer the
# prompt. A headless run fails immediately with the command to run instead of
# hanging on a prompt nobody can see.
#
# Nothing is cloned. No PAT is created, entered, or stored: the installer takes a
# short-lived Azure DevOps token from your Azure CLI session, writes it only to a
# throwaway npm config, and deletes that config before it exits.
#

set -eu
# Every file this installer creates holds a feed token, so keep them owner-only.
umask 077

# ============================================================================
# Configuration
# ============================================================================

# The internal channel's identity -- package, feed registry, feed name, and the
# first-party Azure DevOps application ID the feed token is issued for -- is
# published by the Minions package itself and generated into the block below, so
# a moved feed is one synced artifact instead of four hand-edited literals.
# --- BEGIN GENERATED CHANNEL BLOCK ---
# Generated from minions/internal-channel.json, the checked-in copy of the
# Minions package's own bin/internal-channel.json. Do not edit by hand:
# re-run `bun run minions:channel:sync` instead, which rewrites this block.
# Channel artifact revision: sha256:f32f945fbcbda096ed710bb426df3baedd13b3525e95c7b986a38b12d24cc774
PACKAGE_NAME='@opg-microsoft/minions'
PACKAGE_SCOPE='@opg-microsoft'
FEED_REGISTRY='https://pkgs.dev.azure.com/office/ISS/_packaging/ProjectFeed-ISS/npm/registry/'
FEED_NAME='ProjectFeed-ISS'
ADO_TOKEN_RESOURCE='499b84ac-1321-427f-aa17-267ca6975798'
# --- END GENERATED CHANNEL BLOCK ---

# npm keys auth by protocol-relative registry path, not by the full URL. Derived
# from the generated registry so the two can never disagree.
FEED_AUTH_KEY="${FEED_REGISTRY#http*:}"
# The canonical installer ships inside the package, so the migration flow needs no clone.
CANONICAL_INSTALLER_PATH='bin/install-internal-minions.js'
# The only shape a version pin may take: one exact published version, optionally
# with a prerelease tag. A range or dist-tag resolves to a different build later,
# which is the opposite of pinning, and the value is interpolated into an
# `npm view` argument where a space would start a second option. Shared verbatim
# with install-minions.ps1 and the Constellation test suite.
VERSION_PIN_PATTERN='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'

# ============================================================================
# Helpers
# ============================================================================

step() { printf '\033[36m==> \033[0m%s\n' "$1"; }
ok() { printf '\033[32m==> \033[0m%s\n' "$1"; }
fail() {
  printf '\033[31merror: \033[0m%s\n' "$1" >&2
  exit 1
}

require_command() {
  command -v "$1" >/dev/null 2>&1 || fail "$1 was not found on PATH. $2"
}

# ============================================================================
# Cleanup
# ============================================================================

# Every temporary this launcher creates is registered here and removed on every
# exit path, successful or not: the staging directory carries the feed token's
# npm config, and the diagnostics file carries the Azure CLI's own error text.
# Installed before either exists, so nothing can survive a failure in between.
dir=''
az_stderr_file=''
cleanup() {
  if [ -n "$az_stderr_file" ]; then rm -f "$az_stderr_file"; fi
  if [ -n "$dir" ]; then rm -rf "$dir"; fi
  return 0
}
trap cleanup EXIT INT TERM

# ============================================================================
# Azure sign-in
# ============================================================================

# `az login` is not a prerequisite the operator runs first. This section is the
# whole contract, and install-minions.ps1 implements the same one:
#   1. Try the token. A working session is used silently -- no prompt, no
#      account switch, no re-authentication.
#   2. Classify a failure. Only a genuine ABSENCE of sign-in is recoverable; a
#      missing CLI, an unauthorized identity, and an unrecognized error are
#      reported as themselves rather than laundered into "please log in".
#   3. Sign in at most ONCE, and only where a prompt can actually be answered.
#   4. Retry the token exactly ONCE. There is no loop.

# `--allow-no-subscriptions` is required, not cosmetic: an identity that has
# Azure DevOps access but no Azure *subscription* is the normal shape for this
# feed, and a plain `az login` fails such an account AFTER a successful
# authentication -- which reads to the operator as "sign-in is broken" when the
# sign-in actually worked. Deliberately not `--scope`, `--use-device-code`, or
# `az account set`: nothing here switches, narrows, or persists an identity
# beyond what an ordinary `az login` already does. Shared verbatim with
# install-minions.ps1 and the Constellation test suite.
AZ_LOGIN_ARGUMENTS='login --allow-no-subscriptions'
# The exact command an operator must run when this process may not prompt,
# derived from the arguments above so the command that runs is provably the
# command the guidance names.
AZ_LOGIN_COMMAND="az $AZ_LOGIN_ARGUMENTS"

# Environment markers that mean "no human is watching this terminal". A truthy
# value on any of them turns the sign-in above into a hang rather than a prompt.
# `MINIONS_NONINTERACTIVE` is the explicit opt-out an embedding caller sets --
# Constellation's own install.sh companion phase runs this launcher as a bounded
# child whose terminal it does not own.
NONINTERACTIVE_ENV_KEYS='MINIONS_NONINTERACTIVE
CI
TF_BUILD
GITHUB_ACTIONS
BUILD_BUILDID'

# Lowercase substrings of the Azure CLI's own diagnostics that identify why a
# token request failed. Substrings rather than regular expressions on purpose:
# this is the one grammar .NET, GNU ERE, and BSD ERE agree on byte-for-byte, so
# the two launchers cannot classify the same message differently. Order below is
# load-bearing -- authorization is matched BEFORE sign-in, because several of
# those messages also name `az login` as a generic hint, and following it would
# send an operator who needs a permission grant through a pointless sign-in.
AZ_MISSING_MARKERS='enoent
command not found
no such file or directory
is not recognized as an internal or external command
is not recognized as the name of a cmdlet'
AZ_AUTHORIZATION_MARKERS='aadsts500011
aadsts65001
aadsts53003
does not have authorization
authorizationfailed
insufficient privileges
forbidden'
AZ_SIGN_IN_MARKERS='az login
not logged in
no accounts found
no accounts were found
no account found
no account was found
no subscription found
no subscriptions found
az account set
please run
interactive authentication is needed
reauthenticat
re-authenticat
expired
aadsts50076
aadsts50079
aadsts50173
aadsts700082
aadsts50058
aadsts700084'

# Word splitting on a newline IFS is how both launchers walk these lists; `set
# -f` keeps a value carrying a glob character from expanding into file names.
az_marker_match() {
  _haystack=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
  [ -n "$_haystack" ] || return 1
  _old_ifs=$IFS
  IFS='
'
  set -f
  for _marker in $2; do
    _needle=$(printf '%s' "$_marker" | tr -d '\r')
    if [ -n "$_needle" ]; then
      case "$_haystack" in
        *"$_needle"*)
          IFS=$_old_ifs
          set +f
          return 0
          ;;
      esac
    fi
  done
  IFS=$_old_ifs
  set +f
  return 1
}

# `unknown` deliberately does NOT fall through to a sign-in: prompting for a
# login that was never the problem hides the real error behind a browser window.
az_classify() {
  if [ -z "$(printf '%s' "$1" | tr -d '[:space:]')" ]; then
    printf 'unknown'
    return 0
  fi
  if az_marker_match "$1" "$AZ_MISSING_MARKERS"; then
    printf 'az-missing'
  elif az_marker_match "$1" "$AZ_AUTHORIZATION_MARKERS"; then
    printf 'authorization'
  elif az_marker_match "$1" "$AZ_SIGN_IN_MARKERS"; then
    printf 'sign-in-required'
  else
    printf 'unknown'
  fi
}

az_env_forbids_prompt() {
  _old_ifs=$IFS
  IFS='
'
  set -f
  for _key in $NONINTERACTIVE_ENV_KEYS; do
    # Indirect expansion. `_key` only ever holds a name from the literal list
    # above, so there is nothing here a caller could influence.
    eval "_value=\${$_key:-}"
    _value=$(printf '%s' "$_value" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
    if [ -n "$_value" ] && [ "$_value" != '0' ] && [ "$_value" != 'false' ]; then
      IFS=$_old_ifs
      set +f
      return 0
    fi
  done
  IFS=$_old_ifs
  set +f
  return 1
}

# FAILS CLOSED: both streams must be real terminals and no marker may be set. A
# headless run that guesses wrong does not fail -- it hangs forever on a prompt
# nobody can answer, which is strictly worse than an actionable error.
az_can_prompt() {
  if az_env_forbids_prompt; then return 1; fi
  [ -t 0 ] && [ -t 1 ]
}

# One `az` call. stdout carries the token into a variable; stderr is captured to
# a separate file, so the classifier -- and every message it produces -- read
# stderr only. The token is never printed, logged, or passed as an argument.
az_token=''
az_detail=''
az_token_empty=0
az_token_attempt() {
  az_token=''
  az_detail=''
  az_token_empty=0
  if [ -z "$az_stderr_file" ]; then az_stderr_file=$(mktemp); fi
  : >"$az_stderr_file"
  # `set -e` aborts on a failed assignment, so keep the call in an `||` list --
  # otherwise a signed-out caller never reaches the guidance below.
  _status=0
  az_token=$(az account get-access-token --resource "$ADO_TOKEN_RESOURCE" --query accessToken -o tsv 2>"$az_stderr_file") ||
    _status=$?
  az_detail=$(tr -d '\r' <"$az_stderr_file" 2>/dev/null || true)
  : >"$az_stderr_file"
  az_token=$(printf '%s' "$az_token" | tr -d '\r\n')
  if [ "$_status" -eq 0 ] && [ -n "$az_token" ]; then return 0; fi
  # An exit-0 with no token is an unusable session, not a transport error.
  if [ "$_status" -eq 0 ]; then az_token_empty=1; fi
  az_token=''
  return 1
}

# Operator-facing explanation for a token acquisition that could not be
# recovered. Every line is shared verbatim with install-minions.ps1.
az_fail() {
  _cause=$(printf '%s' "$2" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
  case "$1" in
    az-missing)
      _lines='The Azure CLI (az) was not found on PATH.
  Install it from https://aka.ms/azure-cli, then re-run this installer.'
      ;;
    authorization)
      _lines='Azure sign-in worked, but this identity is not authorized for Azure DevOps.
  This is NOT a sign-in problem -- signing in again will not change it.
  Ask for Azure DevOps access in the office organization, then re-run.'
      ;;
    sign-in-required-noninteractive)
      _lines="No usable Azure CLI session, and this terminal cannot prompt for one.
  Sign in once from an interactive terminal, then re-run this installer:
  $AZ_LOGIN_COMMAND"
      ;;
    login-failed)
      _lines="Interactive sign-in failed.
  Run it yourself, confirm it completes, then re-run this installer:
  $AZ_LOGIN_COMMAND"
      ;;
    sign-in-failed)
      _lines='Sign-in completed, but Azure DevOps still refused to issue a token.
  Your account signed in successfully, so this is an access problem, not a login one.
  Ask for Azure DevOps access in the office organization, then re-run.'
      ;;
    *)
      _lines='Could not acquire an Azure DevOps token from the Azure CLI.
  This was not a missing sign-in, so signing in again is unlikely to help.'
      ;;
  esac
  if [ -n "$_cause" ]; then
    _lines="$_lines
  $_cause"
  fi
  fail "$_lines"
}

acquire_ado_token() {
  if az_token_attempt; then return 0; fi

  if [ "$az_token_empty" -eq 1 ]; then
    _classification='sign-in-required'
  else
    _classification=$(az_classify "$az_detail")
  fi
  if [ "$_classification" != 'sign-in-required' ]; then
    az_fail "$_classification" "$az_detail"
  fi

  if ! az_can_prompt; then
    az_fail 'sign-in-required-noninteractive' "$az_detail"
  fi

  step "No usable Azure CLI session -- signing in once with $AZ_LOGIN_COMMAND"
  # stdio is inherited so the prompt reaches this terminal; `az login` prints no
  # token, so nothing secret can cross this stream. Word splitting is intentional
  # and safe: the arguments are this script's own literal.
  # shellcheck disable=SC2086
  az $AZ_LOGIN_ARGUMENTS || az_fail 'login-failed' ''

  if az_token_attempt; then return 0; fi
  az_fail 'sign-in-failed' "$az_detail"
}

# ============================================================================
# Launcher contract
# ============================================================================

# A digest of everything else in this file, written by `bun run minions:channel:sync`.
# Do not edit by hand: a hand-set revision is exactly the stale stamp this exists
# to prevent.
LAUNCHER_REVISION='sha256:3124a8e1ed48'

# Printed before the pin is validated and before anything is requested,
# downloaded, or installed, so the console output of any run -- successful or
# not -- names the launcher that produced it. A months-old copy in a downloads
# folder is otherwise indistinguishable from the current one.
step "Minions launcher install-minions.sh $LAUNCHER_REVISION"

# ============================================================================
# Resolve the version to install
# ============================================================================

# Pinning is opt-in: with no pin this resolves the newest published version
# exactly as before, and with one it installs that exact build -- the only way
# back to a known-good older release. The environment only seeds the value, so
# the option below wins over a shell-wide pin.
requested_version="${MINIONS_INSTALL_VERSION:-}"
while [ "$#" -gt 0 ]; do
  case "$1" in
    --version)
      [ "$#" -ge 2 ] || fail 'Option --version needs an exact published version, e.g. --version 1.2.3'
      requested_version="$2"
      shift 2
      ;;
    --version=*)
      requested_version="${1#--version=}"
      shift
      ;;
    *)
      fail "Unknown option: $1. The only option is --version <exact published version>."
      ;;
  esac
done

# Checked here -- before the token request, the staging directory, the download,
# and the migrator -- so a typo costs the machine nothing.
if [ -n "$requested_version" ]; then
  pin_rejected="'$requested_version' is not an exact published version. Pass --version 1.2.3 (or set MINIONS_INSTALL_VERSION), or omit both to install the newest published version."
  # `grep` matches line by line, so a value carrying a newline could slip an
  # unmatched second line past the pattern. Rejecting everything outside the
  # grammar's alphabet first is what makes that pattern a whole-value check.
  case "$requested_version" in
    *[!0-9A-Za-z.-]*) fail "$pin_rejected" ;;
  esac
  printf '%s' "$requested_version" | grep -Eq "$VERSION_PIN_PATTERN" || fail "$pin_rejected"
  version_spec="$requested_version"
else
  version_spec='latest'
fi

# ============================================================================
# Prerequisites
# ============================================================================

step 'Checking prerequisites'
require_command node 'Install Node.js 22.5 or newer.'
require_command npm 'Install Node.js 22.5 or newer.'
require_command az 'Install the Azure CLI from https://aka.ms/azure-cli.'

# ============================================================================
# Acquire a short-lived feed token
# ============================================================================

step 'Acquiring a short-lived Azure DevOps token'
acquire_ado_token
token=$az_token
# One live copy of the token, in the variable the npm config below is built from.
az_token=''

# ============================================================================
# Stage the package without touching the global prefix
# ============================================================================

# Deliberately NOT a global install. npm refuses to overwrite a `minions` bin
# shim owned by an earlier public-npm install and aborts with EEXIST, so the
# wrapper stages the package locally and lets the canonical installer -- which
# backs state up and removes that earlier install first -- own the global step.
dir=$(mktemp -d)

# Inherit the caller's npm config so proxy, CA, and registry settings keep applying,
# minus any UTF-8 BOM, which would otherwise make npm ignore its first setting. The
# token never touches the real npmrc.
bom=$(printf '\357\273\277')
sed "1s/^$bom//" "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" 2>/dev/null >"$dir/.npmrc" || true
# A caller npmrc may legally end without a trailing newline, and `>>` would then
# splice the first setting below onto its last line -- corrupting that setting and
# ours, so npm reads a garbage `prefix` and never sees the scope registry.
# Command substitution strips trailing newlines, so this is non-empty only when the
# final byte is not one.
if [ -s "$dir/.npmrc" ] && [ -n "$(tail -c 1 "$dir/.npmrc")" ]; then
  echo >>"$dir/.npmrc"
fi
{
  echo "$PACKAGE_SCOPE:registry=$FEED_REGISTRY"
  echo "$FEED_AUTH_KEY:_authToken=$token"
  echo "$FEED_AUTH_KEY:username=minions"
  echo "$FEED_AUTH_KEY:_password=$(printf %s "$token" | base64 | tr -d '\n')"
  echo "$FEED_AUTH_KEY:email=minions@internal.invalid"
} >>"$dir/.npmrc"

# Proves feed read access before anything on the machine changes, so a missing
# permission fails safely and leaves the host untouched. The resolved version is
# reused below so the staged installer and the package it installs cannot drift
# apart across a publish that lands mid-run.
step "Verifying $FEED_NAME read access for $PACKAGE_NAME@$version_spec"
# Deliberately not a sign-in prescription: a token was already issued, so looping
# back through `az login` would change nothing here.
target_version=$(npm view "$PACKAGE_NAME@$version_spec" version --registry "$FEED_REGISTRY" --userconfig "$dir/.npmrc") ||
  fail "Could not read $PACKAGE_NAME@$version_spec from $FEED_NAME. Request Azure Artifacts read permission on the feed -- or check that a pinned version exists -- then re-run.
  The Azure sign-in already worked, so this is feed read permission, not a login."
[ -n "$target_version" ] || fail "$FEED_NAME did not report a version for $PACKAGE_NAME@$version_spec."
# A pin that resolves to some other build is the feed answering a different
# question; installing that answer would silently defeat the pin.
if [ -n "$requested_version" ] && [ "$target_version" != "$requested_version" ]; then
  fail "$FEED_NAME resolved $PACKAGE_NAME@$requested_version to $target_version; refusing to install a version other than the pinned one."
fi

# `--prefix` keeps this install inside the throwaway directory: its bin links land
# in `$dir/node_modules/.bin`, never beside the global `minions` shim. `--global=false`
# neutralises a `global=true` inherited from the caller's npmrc above, which would
# otherwise switch npm to the global layout under that prefix (`$dir/lib/node_modules`)
# and leave the hand-off below unable to find the staged migrator.
step "Staging $PACKAGE_NAME@$target_version"
npm install "$PACKAGE_NAME@$target_version" --prefix "$dir" --global=false --no-save --ignore-scripts --no-audit --no-fund --registry "$FEED_REGISTRY" --userconfig "$dir/.npmrc" ||
  fail "Could not stage $PACKAGE_NAME@$target_version from $FEED_NAME."

# The canonical installer acquires its own short-lived token, so the staging config
# has no reason to outlive the staging step.
rm -f "$dir/.npmrc"
unset token

# ============================================================================
# Run the canonical installer
# ============================================================================

# It owns the whole cutover and its ordering: state backup, removal of an earlier
# public-npm install, orphaned-shim repair, the global install of the internal
# package, `minions init`, and a health-verified restart. Idempotent: re-run this
# script any time to upgrade.
step 'Running the canonical Minions installer'
node "$dir/node_modules/$PACKAGE_NAME/$CANONICAL_INSTALLER_PATH" --version "$target_version" ||
  fail 'The canonical Minions installer failed. Re-run this script after resolving the reported issue.'

ok 'Minions is installed. Next: minions doctor'
