#!/usr/bin/env bash
# Copyright © 2026 UiPath. Proprietary — use of this product is subject to
# the licence shipped as LICENSE.txt in the installed package.
#
# One-liner onboarding installer for the UiPath CLI (`uip`).
#
# Takes a bare macOS/Linux machine to a working `uip` setup:
#   0. Ensure curl is available (used to download the installers below).
#   1. Ensure Node.js >= 20 is installed.
#   2. npm install -g @uipath/cli (from public npmjs — no token needed).
#   3. uip skills install (UiPath skills for whichever AI agents are installed).
#   4. Install the .NET SDK 8.0 (the workflow compiler is built against it).
#   5. Install the .NET 10 runtime (`uip rpa pack` and `uip codedapp convert`
#      run on it).
#   6. Install Python (3.11–3.15).
#
# Every step is idempotent: it detects what's already present and skips it,
# so re-running is safe.
#
# Usage (one-liner):
#   curl -fsSL https://download.uipath.com/uipath-cli/install.sh | bash
#
# With options through the pipe:
#   curl -fsSL .../install.sh | bash -s -- --skip-python
#   curl -fsSL .../install.sh | bash -s -- --agent codex
#   UIP_SKIP_PYTHON=1 bash -c "$(curl -fsSL .../install.sh)"
#
# Options (each also has an env var, handy for the piped form):
#   --skip-node       UIP_SKIP_NODE      Don't touch Node.js.
#   --skip-skills     UIP_SKIP_SKILLS    Don't run `uip skills install`.
#   --agent <agent>   UIP_SKILLS_AGENT   Install skills for one agent.
#   --skip-dotnet     UIP_SKIP_DOTNET    Don't install .NET (SDK 8 + runtime 10).
#   --skip-python     UIP_SKIP_PYTHON    Don't install Python.
#   --skip-runtimes   UIP_SKIP_RUNTIMES  Shorthand for --skip-dotnet --skip-python.
#   --dry-run         UIP_DRY_RUN        Print what would run; change nothing.
#   -h, --help                           Show this help and exit.
#
# Status and errors go to stderr. Never suppress stderr when running this.

set -euo pipefail

# ── Versions / sources ────────────────────────────────────────────────────────
NODE_MIN_MAJOR=20
DOTNET_CHANNEL="8.0"            # SDK: the workflow compiler is built against it
DOTNET_RUNTIME_CHANNEL="10.0"   # runtime: `uip rpa pack` / `uip codedapp convert` run on it
PYTHON_DEFAULT="3.12"           # installed when no 3.11–3.15 is found
PYTHON_CANDIDATES=(python3.14 python3.13 python3.12 python3.11 python3.15 python3 python)
DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh"
# Where the Homebrew .NET casks land. They ship Microsoft's own .pkg, which
# installs to the standard macOS location and is then symlinked into the brew
# prefix (the casks' `binary` artifact is /usr/local/share/dotnet/dotnet with
# target $HOMEBREW_PREFIX/bin/dotnet), so a cask extends this root rather than
# making one of its own.
DOTNET_MACOS_CASK_ROOT="/usr/local/share/dotnet"

# ── Options (env vars are the defaults; flags override below) ─────────────────
truthy() { case "${1:-}" in 1|true|TRUE|yes|YES) return 0 ;; *) return 1 ;; esac; }
SKIP_NODE=$(truthy "${UIP_SKIP_NODE:-}" && echo 1 || echo 0)
SKIP_SKILLS=$(truthy "${UIP_SKIP_SKILLS:-}" && echo 1 || echo 0)
SKIP_DOTNET=$(truthy "${UIP_SKIP_DOTNET:-}" && echo 1 || echo 0)
SKIP_PYTHON=$(truthy "${UIP_SKIP_PYTHON:-}" && echo 1 || echo 0)
SKILLS_AGENT="${UIP_SKILLS_AGENT:-}"
DRY_RUN=$(truthy "${UIP_DRY_RUN:-}" && echo 1 || echo 0)
if truthy "${UIP_SKIP_RUNTIMES:-}"; then SKIP_DOTNET=1; SKIP_PYTHON=1; fi

usage() { sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//'; }

# Keep in sync with ALL_AGENTS in packages/cli/src/commands/skills/prompt.ts.
VALID_SKILLS_AGENTS=(claude cursor copilot codex opencode autopilot kiro windsurf antigravity)

format_skills_agents() {
    local first=1 agent
    for agent in "${VALID_SKILLS_AGENTS[@]}"; do
        if [[ $first -eq 1 ]]; then
            printf '%s' "$agent"
            first=0
        else
            printf ', %s' "$agent"
        fi
    done
}

validate_skills_agent() {
    local candidate="$1" agent
    if [[ -z "$candidate" ]]; then
        echo "missing value for --agent" >&2
        exit 2
    fi
    for agent in "${VALID_SKILLS_AGENTS[@]}"; do
        [[ "$candidate" == "$agent" ]] && return 0
    done
    echo "unknown agent: $candidate (valid: $(format_skills_agents))" >&2
    exit 2
}

normalize_skills_agent() {
    printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]'
}

set_skills_agent() {
    local candidate
    candidate=$(normalize_skills_agent "$1")
    validate_skills_agent "$candidate"
    SKILLS_AGENT="$candidate"
}

while [[ $# -gt 0 ]]; do
    case "$1" in
        --skip-node)     SKIP_NODE=1 ;;
        --skip-skills)   SKIP_SKILLS=1 ;;
        --agent)         [[ $# -ge 2 ]] || { echo "missing value for --agent" >&2; exit 2; }; set_skills_agent "$2"; shift ;;
        --agent=*)       set_skills_agent "${1#*=}" ;;
        --skip-dotnet)   SKIP_DOTNET=1 ;;
        --skip-python)   SKIP_PYTHON=1 ;;
        --skip-runtimes) SKIP_DOTNET=1; SKIP_PYTHON=1 ;;
        --dry-run)       DRY_RUN=1 ;;
        -h|--help)       usage; exit 0 ;;
        *) echo "unknown flag: $1 (try --help)" >&2; exit 2 ;;
    esac
    shift
done
if [[ $SKIP_SKILLS -eq 0 && -n "$SKILLS_AGENT" ]]; then
    set_skills_agent "$SKILLS_AGENT"
fi

# ── Output helpers (everything to stderr; never suppressed) ───────────────────
step()  { printf '\n\033[1;36m==>\033[0m %s\n' "$*" >&2; }
info()  { printf '    %s\n' "$*" >&2; }
ok()    { printf '\033[1;32m    ✓ %s\033[0m\n' "$*" >&2; }
warn()  { printf '\033[1;33m    ! %s\033[0m\n' "$*" >&2; }
die()   { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; exit 1; }

warn_skills_no_agent() {
    warn "No supported AI coding agent was detected; skills were not installed. Install one, or re-run with --agent <agent> (valid: $(format_skills_agents))."
}

shell_quote() {
    printf '%q' "$1"
}

# Run a mutating command — or, in --dry-run, just print it.
run() {
    if [[ $DRY_RUN -eq 1 ]]; then
        printf '\033[2m    [dry-run] %s\033[0m\n' "$*" >&2
        return 0
    fi
    "$@"
}

have() { command -v "$1" >/dev/null 2>&1; }
is_macos() { [[ "$(uname -s)" == "Darwin" ]]; }

# ── Homebrew (arch-aware) ─────────────────────────────────────────────────────
# On Apple Silicon a leftover Intel Homebrew at /usr/local/bin/brew (from Rosetta
# or an older setup) can shadow the arm64 one on PATH. Using it pulls x64 casks —
# most painfully the x64 .NET SDK, which installs `dotnet` under
# /usr/local/share/dotnet/x64/ while Homebrew's post-install symlink step looks
# for it at /usr/local/share/dotnet/, so the cask fails. Prefer the Homebrew
# whose prefix matches this machine's architecture.
#
# BREW              path to the brew we'll use ("" if none found)
# BREW_ARCH_MATCH   1 when BREW matches this machine's architecture
BREW=""
BREW_ARCH_MATCH=0
resolve_brew() {
    if ! is_macos; then
        have brew && BREW="brew"
        # On Linux there's a single Homebrew prefix, so no arch mismatch to worry
        # about — treat whatever we found as a match.
        [[ -n "$BREW" ]] && BREW_ARCH_MATCH=1
        # Return success: this sets globals, it's not a predicate. Without the
        # explicit 0 the exit status is the failed `[[ -z ]]` test above, which
        # under `set -e` aborts the whole installer right after startup on any
        # box without brew (every Linux/Windows CI runner).
        return 0
    fi
    local preferred
    if [[ "$(uname -m)" == "arm64" ]]; then preferred="/opt/homebrew/bin/brew"
    else preferred="/usr/local/bin/brew"; fi
    if [[ -x "$preferred" ]]; then
        BREW="$preferred"; BREW_ARCH_MATCH=1
    elif have brew; then
        # Only a mismatched-arch Homebrew is on PATH. Usable for plain formulae,
        # but callers that care about arch (the .NET cask) should avoid it.
        BREW="$(command -v brew)"; BREW_ARCH_MATCH=0
    fi
    # Same reason as above: a macOS box with no brew at all leaves the `elif`
    # test as the last status (non-zero), so make the resolver always succeed.
    return 0
}

# Print the command that downloads a URL to stdout — curl or wget, whichever
# exists (minimal images often ship only one). Empty when neither is present.
downloader_cmd() {
    if have curl; then echo "curl -fsSL"
    elif have wget; then echo "wget -qO-"
    fi
}

# Tracks what we did, for the closing summary.
SUMMARY=()
note() { SUMMARY+=("$1"); }

# rehash so a tool installed this run resolves on PATH without a new shell.
refresh_path() { hash -r 2>/dev/null || true; }

# The `uip` binary installed by this run. Keep this explicit so verification and
# skills install do not accidentally use an older binary earlier on PATH.
UIP_BIN="uip"

path_has_dir() {
    local dir="$1"
    case ":$PATH:" in
        *":$dir:"*) return 0 ;;
        *) return 1 ;;
    esac
}

is_under_home() {
    local p="$1"
    [[ "$p" == "$HOME" || "$p" == "$HOME/"* ]]
}

# Append a guarded PATH export to the user's shell rc (once).
add_to_rc() {
    local line="$1" rc
    case "${SHELL##*/}" in
        zsh)  rc="$HOME/.zshrc" ;;
        bash) rc="$HOME/.bashrc" ;;
        *)    rc="$HOME/.profile" ;;
    esac
    if [[ $DRY_RUN -eq 1 ]]; then
        info "[dry-run] would add to $rc: $line"
        return 0
    fi
    if [[ ! -f "$rc" ]]; then
        if ! touch "$rc"; then
            warn "Could not update $rc. Add this line manually: $line"
            return 0
        fi
    fi
    if ! grep -qsF "$line" "$rc"; then
        if printf '\n# Added by uip install.sh\n%s\n' "$line" >>"$rc"; then
            info "Added PATH entry to $rc"
        else
            warn "Could not update $rc. Add this line manually: $line"
        fi
    fi
    return 0
}

# ── 0. curl ───────────────────────────────────────────────────────────────────
# The .NET installer and fnm are fetched over the network; make sure curl is
# present (minimal Linux images often ship without it). Best-effort — wget also
# works as a fallback, so a failure here is not fatal.
ensure_curl() {
    step "curl"
    if have curl; then ok "curl already installed"; note "curl: present"; return; fi
    if is_macos; then
        [[ -n "$BREW" ]] && run "$BREW" install curl
    else
        local sudo=""; [[ "$(id -u)" -ne 0 ]] && have sudo && sudo="sudo"
        if   have apt-get; then run $sudo apt-get update || true; run $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y curl || true
        elif have dnf;     then run $sudo dnf install -y curl || true
        elif have yum;     then run $sudo yum install -y curl || true
        elif have pacman;  then run $sudo pacman -Sy --noconfirm curl || true
        elif have apk;     then run $sudo apk add --no-cache curl || true
        fi
    fi
    refresh_path
    if [[ $DRY_RUN -eq 0 ]]; then
        if have curl; then ok "curl installed"; note "curl: installed"
        else warn "Could not install curl — wget will be used if available."; note "curl: not installed"; fi
    fi
}

# ── 1. Node.js >= 20 ──────────────────────────────────────────────────────────
node_major() { node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/'; }
# True when node is present AND at least NODE_MIN_MAJOR.
node_ok() { local m; m=$(node_major); [[ -n "$m" && "$m" -ge "$NODE_MIN_MAJOR" ]]; }

ensure_node() {
    step "Node.js >= ${NODE_MIN_MAJOR}"
    if [[ $SKIP_NODE -eq 1 ]]; then warn "skipped (--skip-node)"; note "Node: skipped"; return; fi

    if node_ok; then
        ok "Node $(node -v) already installed"; note "Node: present ($(node -v))"; return
    fi
    have node && warn "Node $(node -v) is older than v${NODE_MIN_MAJOR}; upgrading"

    if is_macos; then
        if [[ -n "$BREW" ]]; then run "$BREW" install node; else install_node_fnm; fi
    else
        install_node_linux
    fi
    refresh_path

    # Distro packages are often older than v20 (e.g. apt ships v18). If what we
    # got is still too old (or missing), fall back to fnm, which installs the
    # current LTS into user space.
    if [[ $DRY_RUN -eq 0 ]] && ! node_ok; then
        warn "Node $(node -v 2>/dev/null || echo 'not found') is below v${NODE_MIN_MAJOR}; installing the LTS via fnm"
        install_node_fnm
        refresh_path
    fi

    if [[ $DRY_RUN -eq 0 ]]; then
        node_ok || die "Node >= v${NODE_MIN_MAJOR} could not be installed. Install it from https://nodejs.org and re-run."
        ok "Node $(node -v) installed"
    fi
    note "Node: installed"
}

install_node_linux() {
    local sudo=""; [[ "$(id -u)" -ne 0 ]] && have sudo && sudo="sudo"
    local dl; dl=$(downloader_cmd)
    # Debian/Ubuntu and RHEL/Fedora ship an old Node in their default repos
    # (apt's is v18, below our minimum), so add NodeSource for a current LTS.
    # NodeSource's `nodejs` package bundles npm.
    if   have apt-get; then
        [[ -n "$dl" ]] && run bash -c "$dl https://deb.nodesource.com/setup_lts.x | $sudo bash -"
        run $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
    elif have dnf;     then
        [[ -n "$dl" ]] && run bash -c "$dl https://rpm.nodesource.com/setup_lts.x | $sudo bash -"
        run $sudo dnf install -y nodejs
    elif have yum;     then
        [[ -n "$dl" ]] && run bash -c "$dl https://rpm.nodesource.com/setup_lts.x | $sudo bash -"
        run $sudo yum install -y nodejs
    elif have pacman;  then run $sudo pacman -Sy --noconfirm nodejs npm
    elif have apk;     then run $sudo apk add --no-cache nodejs npm
    else install_node_fnm; fi
}

# User-space Node via fnm — no sudo, no system package manager needed.
install_node_fnm() {
    info "Installing Node via fnm (user-space, no sudo)"
    local dl; dl=$(downloader_cmd)
    [[ -n "$dl" ]] || die "curl or wget is required to install Node. Install Node manually from https://nodejs.org"
    # fnm's installer unpacks a .zip, so it needs unzip — grab it if missing.
    if ! have fnm && ! have unzip; then
        local sudo=""; [[ "$(id -u)" -ne 0 ]] && have sudo && sudo="sudo"
        if   have apt-get; then run $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y unzip || true
        elif have dnf;     then run $sudo dnf install -y unzip || true
        elif have yum;     then run $sudo yum install -y unzip || true
        elif have pacman;  then run $sudo pacman -Sy --noconfirm unzip || true
        elif have apk;     then run $sudo apk add --no-cache unzip || true
        elif [[ -n "$BREW" ]]; then run "$BREW" install unzip || true
        fi
    fi
    if ! have fnm; then
        run bash -c "$dl https://fnm.vercel.app/install | bash -s -- --skip-shell"
    fi
    local fnm_dir="$HOME/.local/share/fnm"
    [[ -d "$fnm_dir" ]] || fnm_dir="$HOME/.fnm"
    export PATH="$fnm_dir:$PATH"
    add_to_rc "export PATH=\"$fnm_dir:\$PATH\""
    # shellcheck disable=SC2016 # we want the literal string in the rc, not expanded now
    add_to_rc 'eval "$(fnm env --use-on-cd)"'
    if [[ $DRY_RUN -eq 0 ]]; then
        eval "$(fnm env)" 2>/dev/null || true
        run fnm install --lts
        run fnm default lts-latest
        eval "$(fnm env --use-on-cd)" 2>/dev/null || true
    else
        run fnm install --lts
    fi
}

# ── 2. UiPath CLI ─────────────────────────────────────────────────────────────
# True when npm's output shows a filesystem-permission failure (EPERM/EACCES) —
# the one class a user-writable prefix can fix — rather than a network, registry,
# or auth failure. Mirrors Is-NpmPermissionError in install.ps1 (#2990) so both
# installers only retry under a user prefix when that can actually help.
npm_is_permission_error() {
    # No `\b` word boundaries: they are a GNU grep extension, undefined in POSIX
    # ERE, and BSD/macOS grep treats `\b` literally — which would fail to match
    # EPERM/EACCES and skip the fallback on macOS. EPERM/EACCES are distinctive
    # enough that dropping the boundaries doesn't over-match npm's other output.
    printf '%s' "${1:-}" | grep -qiE 'EPERM|EACCES|operation not permitted|permission denied|access is denied'
}

# Name the problem when a global install fails for a reason a user prefix can't
# fix and we recognize it. The common field failure: an .npmrc that scopes
# @uipath to an authenticated registry (e.g. GitHub Packages) with no/stale
# token, so installing the public CLI 401s. Point the user straight at the fix.
diagnose_cli_install_failure() {
    local out="${1:-}"
    if printf '%s' "$out" | grep -qiE 'E401|ENEEDAUTH|401[[:space:]]+Unauthorized|need(s|ed)?[[:space:]]+auth'; then
        warn "npm could not authenticate to install @uipath/cli."
        info "Your npm config may route @uipath packages to a private registry that needs a token."
        info "Check it:  npm config get @uipath:registry"
        info "If it is not https://registry.npmjs.org/, remove the override:  npm config delete @uipath:registry"
        info "Or install once from public npmjs:  npm install -g @uipath/cli --@uipath:registry=https://registry.npmjs.org/"
        info "Then re-run this installer."
    fi
}

ensure_cli() {
    step "UiPath CLI (@uipath/cli)"
    have npm || die "npm not found (it ships with Node). Re-run after Node is installed."

    if [[ $DRY_RUN -eq 1 ]]; then
        run npm install -g @uipath/cli
        UIP_BIN="uip"
        note "CLI: installed"
        return
    fi

    # Capture the attempt so the failure can be classified (permission vs auth vs
    # other) instead of blindly retrying — then echo it so nothing is hidden.
    local prefix="" npm_out rc=0
    npm_out=$(npm install -g @uipath/cli 2>&1) || rc=$?
    [[ -n "$npm_out" ]] && printf '%s\n' "$npm_out" >&2

    if [[ $rc -ne 0 ]]; then
        if npm_is_permission_error "$npm_out"; then
            # Default global prefix not writable — retry under a user prefix, no sudo.
            prefix="$HOME/.npm-global"
            warn "Global install failed; retrying with a user npm prefix: $prefix"
            local retry_out rrc=0
            retry_out=$(install_cli_user_prefix "$prefix" 2>&1) || rrc=$?
            [[ -n "$retry_out" ]] && printf '%s\n' "$retry_out" >&2
            if [[ $rrc -ne 0 ]]; then
                diagnose_cli_install_failure "$retry_out"
                die "npm install -g @uipath/cli --prefix $prefix failed."
            fi
        else
            # A network/registry/auth failure would just fail again under a user
            # prefix and bury the real cause. Diagnose what we can, then stop.
            diagnose_cli_install_failure "$npm_out"
            die "npm install -g @uipath/cli failed."
        fi
    elif ! prefix=$(npm prefix -g); then
        die "@uipath/cli installed, but npm did not report its global prefix."
    fi

    configure_cli_bin "$prefix"
    note "CLI: installed"
}

install_cli_user_prefix() {
    local prefix="$1"
    local cache="$prefix/.npm-cache"

    mkdir -p "$prefix" "$cache"
    npm install -g \
        --prefix "$prefix" \
        --cache "$cache" \
        @uipath/cli
}

configure_cli_bin() {
    local prefix="$1"
    local bin_dir="$prefix/bin"
    local installed="$bin_dir/uip"
    local bin_was_on_path=0

    [[ -x "$installed" ]] || die "@uipath/cli installed, but expected binary is missing: $installed"

    local installed_version path_uip path_version
    installed_version=$(UIPATH_CLI_DISABLE_VERSION_SYNC=true "$installed" --version || echo "?")
    path_uip=$(type -P uip || true)
    path_has_dir "$bin_dir" && bin_was_on_path=1
    if [[ -z "$path_uip" ]]; then
        warn "\`uip\` is not on PATH yet; using $installed for the rest of this installer run."
    elif [[ "$path_uip" != "$installed" ]]; then
        path_version=$(UIPATH_CLI_DISABLE_VERSION_SYNC=true "$path_uip" --version || echo "?")
        warn "Another \`uip\` is earlier on PATH: $path_uip ($path_version). Installed CLI is $installed ($installed_version)."
    fi

    if ! path_has_dir "$bin_dir"; then
        export PATH="$bin_dir:$PATH"
        info "Using $bin_dir first on PATH for this installer run"
    fi
    if is_under_home "$prefix" && { [[ $bin_was_on_path -eq 0 ]] || [[ "$prefix" == "$HOME/.npm-global" ]]; }; then
        add_to_rc "export PATH=\"$bin_dir:\$PATH\""
    fi
    UIP_BIN="$installed"
    refresh_path
    ok "uip $installed_version installed at $installed"
}

# ── 3. UiPath skills ──────────────────────────────────────────────────────────
ensure_skills() {
    step "UiPath skills for AI coding agents"
    if [[ $SKIP_SKILLS -eq 1 ]]; then warn "skipped (--skip-skills)"; note "Skills: skipped"; return; fi
    # No --agent: the CLI auto-detects every installed agent and installs for
    # each, printing its own result. --no-interactive keeps it from popping the
    # agent picker in a terminal. --skip-if-no-agent keeps one-liners
    # recoverable for web/IDE agents that do not put an agent CLI on PATH.
    # Use the binary installed by this script so an older `uip` earlier on PATH
    # cannot reject newer global options.
    local cmd=("$UIP_BIN" skills install --no-interactive --skip-if-no-agent --output json)
    if [[ -n "$SKILLS_AGENT" ]]; then cmd+=(--agent "$SKILLS_AGENT"); fi
    if [[ $DRY_RUN -eq 1 ]]; then
        run "${cmd[@]}"
        note "Skills: (dry-run)"
        return
    fi

    local output
    if output=$("${cmd[@]}"); then
        [[ -z "$output" ]] || printf '%s\n' "$output"
        if [[ "$output" =~ \"Skipped\"[[:space:]]*:[[:space:]]*true ]]; then
            warn_skills_no_agent
            note "Skills: skipped (no agent detected)"
            return
        fi
    else
        [[ -z "$output" ]] || printf '%s\n' "$output"
        # shell_quote emits a backslash-escaped token (printf %q); don't wrap it
        # in quotes or the escapes become literal when the user copies the line.
        warn "Skills install did not complete. You can run $(shell_quote "$UIP_BIN") skills install --agent <agent> later."
        note "Skills: skipped (install failed)"
        return
    fi
    note "Skills: see output above"
}

# ── 4. .NET SDK 8.0 ───────────────────────────────────────────────────────────
has_dotnet8() {
    have dotnet || return 1
    dotnet --list-sdks 2>/dev/null | grep -q '^8\.' && return 0
    [[ "$(dotnet --version 2>/dev/null)" == 8.* ]]
}

# The root of the `dotnet` already on PATH, read from its own output rather than
# by chasing symlinks: `--list-sdks` prints "<ver> [<root>/sdk]" and
# `--list-runtimes` prints "<name> <ver> [<root>/shared/<name>]". Non-zero when
# there is no dotnet, or it reports neither.
dotnet_root() {
    have dotnet || return 1
    local root=""
    # One listing at a time: feeding both into `head -1` makes the second one
    # take a SIGPIPE, which `pipefail` then reports as a failed lookup.
    root=$(dotnet --list-sdks 2>/dev/null | sed -n '1s#.*\[\(.*\)/sdk\]$#\1#p') || root=""
    if [[ -z "$root" ]]; then
        root=$(dotnet --list-runtimes 2>/dev/null | sed -n '1s#.*\[\(.*\)/shared/[^/]*\]$#\1#p') || root=""
    fi
    [[ -n "$root" ]] || return 1
    printf '%s\n' "$root"
}

# True when `dotnet` already resolves to a root we can add a version to. Both
# steps check it before reaching for a Homebrew cask: the cask installs into the
# system location, so an active root anywhere else would be left short.
dotnet_has_writable_root() {
    local root
    root=$(dotnet_root) || return 1
    [[ -w "$root" ]]
}

# True when a cask would land in the root `dotnet` already resolves to — or when
# there is no dotnet yet, so there is nothing for it to hide. Guards the case
# `dotnet_has_writable_root` cannot distinguish: it says no both for "no dotnet"
# and for "a root we cannot write to", and only the first of those is safe to
# answer with a cask. A root we cannot write to that the cask does not own (an
# SDK under /opt/dotnet, say) has to fall through to the refusal instead.
dotnet_cask_extends_active_root() {
    local root
    root=$(dotnet_root) || return 0
    [[ "$root" == "$DOTNET_MACOS_CASK_ROOT" ]]
}

# Install a .NET component with Microsoft's official user-space installer (no
# brew, no sudo). It fetches the arch-matching build for this machine, so it
# sidesteps the x64-cask symlink breakage. $1 is the channel; pass "dotnet" as
# $2 to install the shared runtime instead of the SDK. Returns non-zero on
# failure so callers can note it and move on. Sets PATH/DOTNET_ROOT for this run
# and the user's shell rc.
install_dotnet_official() {
    local channel="$1" runtime="${2:-}"
    local what=".NET SDK"; [[ -n "$runtime" ]] && what=".NET runtime"
    local dl; dl=$(downloader_cmd)
    if [[ -z "$dl" ]]; then
        warn "Need curl or wget to install the ${what} — skipping. See https://dotnet.microsoft.com/download"
        return 1
    fi
    # .NET 7 dropped multi-level lookup, so a single root has to hold every
    # version we need. Extend the root `dotnet` already resolves to when it is
    # writable; when it is not, refuse rather than drop a second root in front
    # of it on PATH, which would hide what it holds — including the SDK 8 this
    # script may have just skipped because it was already there. With no dotnet
    # at all there is nothing to hide, so the per-user default is free.
    local install_dir existing=""
    existing=$(dotnet_root) || existing=""
    if [[ -n "$existing" && ! -w "$existing" ]]; then
        warn "${what} ${channel} is missing, but the .NET at $existing is not writable and a second root would come first on PATH and hide it. Add ${what} ${channel} to that root instead — with the package manager that installed it, or from https://dotnet.microsoft.com/download."
        return 1
    fi
    install_dir="${existing:-$HOME/.dotnet}"
    # Every value goes through shell_quote: this string is handed to `bash -c`,
    # and wrapping it in literal single quotes breaks on a path that contains
    # one — /Users/o'brien/.dotnet closes the quote and the whole command
    # becomes a syntax error instead of an install.
    local args="--channel $(shell_quote "$channel") --install-dir $(shell_quote "$install_dir")"
    [[ -n "$runtime" ]] && args="$args --runtime $(shell_quote "$runtime")"
    info "Installing ${what} ${channel} to $install_dir (official installer)"
    if ! run bash -c "$dl $(shell_quote "$DOTNET_INSTALL_URL") | bash -s -- $args"; then
        warn "Could not download or run the .NET installer — skipping ${what} ${channel}."
        return 1
    fi
    export PATH="$install_dir:$PATH"
    export DOTNET_ROOT="$install_dir"
    add_to_rc "export DOTNET_ROOT=\"$install_dir\""
    add_to_rc "export PATH=\"$install_dir:\$PATH\""
    # Return success explicitly: without this the function's exit status is
    # add_to_rc's, so a non-zero rc-file write would make callers report a
    # successful install as "skipped (install failed)".
    return 0
}

# Why the Homebrew cask was passed over, when there was one to pass over. Two
# different reasons land in the same branch, and calling the second one an
# architecture mismatch sends the reader looking at the wrong thing.
explain_cask_skip() {
    is_macos && [[ -n "$BREW" ]] || return 0
    if [[ $BREW_ARCH_MATCH -ne 1 ]]; then
        info "Skipping the Homebrew cask: the brew on PATH ($BREW) doesn't match this machine's architecture ($(uname -m)); using the official installer instead."
    else
        info "Skipping the Homebrew cask: it only ever extends $DOTNET_MACOS_CASK_ROOT, which is not the active .NET root; using the official installer instead."
    fi
}

# Install one .NET component. The SDK step and the runtime step differ only by
# these three inputs, so the choice of installer lives here once:
#   $1 channel  — 8.0, 10.0
#   $2 runtime  — "" for the SDK, "dotnet" for the shared runtime
#   $3 cask     — the Homebrew cask that carries it on macOS
# Non-zero when nothing was installed; the reason is already printed.
install_dotnet_component() {
    local channel="$1" runtime="$2" cask="$3"

    # A writable root that `dotnet` already resolves to wins: a cask installs
    # into the system location, which would leave the active `dotnet` short.
    # Otherwise use the cask, but only with an arch-matching brew. A
    # mismatched-arch brew (Intel brew on Apple Silicon) installs the x64 cask,
    # whose `dotnet` lands under .../x64/ and breaks the cask's own symlink step,
    # leaving .NET broken. In that case (and everywhere off-macOS) use the
    # official Microsoft installer, which fetches the arch-matching build.
    if dotnet_has_writable_root; then
        install_dotnet_official "$channel" "$runtime" || return 1
    elif is_macos && [[ -n "$BREW" && $BREW_ARCH_MATCH -eq 1 ]] && dotnet_cask_extends_active_root; then
        if ! run "$BREW" install --cask "$cask"; then
            warn "brew install $cask failed — falling back to the official .NET installer."
            install_dotnet_official "$channel" "$runtime" || return 1
        fi
    else
        explain_cask_skip
        install_dotnet_official "$channel" "$runtime" || return 1
    fi
    refresh_path
}

ensure_dotnet() {
    step ".NET SDK ${DOTNET_CHANNEL}"
    if [[ $SKIP_DOTNET -eq 1 ]]; then warn "skipped (--skip-dotnet)"; note ".NET: skipped"; return; fi
    if has_dotnet8; then ok ".NET SDK 8.x already installed"; note ".NET: present"; return; fi

    # Best-effort: a failure here (no downloader, no network) shouldn't abort the
    # whole installer — warn, note it skipped, and move on to the next step.
    install_dotnet_component "$DOTNET_CHANNEL" "" "dotnet-sdk@8" \
        || { note ".NET: not installed (see the message above)"; return; }

    if [[ $DRY_RUN -eq 1 ]]; then note ".NET: installed"; return; fi
    if has_dotnet8; then
        ok ".NET SDK installed"
        note ".NET: installed"
    else
        warn ".NET installed; open a new shell to pick it up on PATH"
        note ".NET: installed, but not visible to the active dotnet"
    fi
}

# ── 5. .NET runtime 10.0 ──────────────────────────────────────────────────────
# `uip rpa pack` runs the Studio Helm workflow compiler and `uip codedapp
# convert` runs the Roslyn bridge. Both are net10.0 apps, so .NET 10 has to be
# there on top of the SDK 8 above — with only .NET 8 installed, `uip rpa pack`
# dies before it reads the project ("You must install or update .NET to run this
# application ... Framework: 'Microsoft.NETCore.App', version '10.0.0'").
has_dotnet10_runtime() {
    have dotnet || return 1
    dotnet --list-runtimes 2>/dev/null | grep -q '^Microsoft\.NETCore\.App 10\.'
}

ensure_dotnet_runtime() {
    step ".NET runtime ${DOTNET_RUNTIME_CHANNEL}"
    if [[ $SKIP_DOTNET -eq 1 ]]; then warn "skipped (--skip-dotnet)"; note ".NET runtime: skipped"; return; fi
    if has_dotnet10_runtime; then ok ".NET 10 runtime already installed"; note ".NET runtime: present"; return; fi

    # The cask tracks the current major rather than pinning 10 (there is no
    # dotnet-runtime@10) — the check after the install catches the day it moves
    # past 10.
    install_dotnet_component "$DOTNET_RUNTIME_CHANNEL" dotnet "dotnet-runtime" \
        || { note ".NET runtime: not installed (see the message above)"; return; }

    if [[ $DRY_RUN -eq 1 ]]; then note ".NET runtime: installed"; return; fi
    # The closing summary has to agree with the warning above it: when the
    # runtime did not land where the active `dotnet` can see it, "installed" in
    # the summary is the only line most users read, and it would be wrong.
    if has_dotnet10_runtime; then
        ok ".NET 10 runtime installed"
        note ".NET runtime: installed"
    else
        warn ".NET runtime installed, but 'dotnet --list-runtimes' shows no Microsoft.NETCore.App 10.x — open a new shell, or install it from https://dotnet.microsoft.com/download/dotnet/10.0"
        note ".NET runtime: installed, but not visible to the active dotnet"
    fi
}

# ── 6. Python 3.11–3.15 ───────────────────────────────────────────────────────
find_python() {
    local p ver
    for p in "${PYTHON_CANDIDATES[@]}"; do
        have "$p" || continue
        ver=$("$p" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null) || continue
        case "$ver" in 3.11|3.12|3.13|3.14|3.15) echo "$p $ver"; return 0 ;; esac
    done
    return 1
}

ensure_python() {
    step "Python 3.11–3.15"
    if [[ $SKIP_PYTHON -eq 1 ]]; then warn "skipped (--skip-python)"; note "Python: skipped"; return; fi

    local found
    if found=$(find_python); then ok "Python ${found#* } already installed (${found%% *})"; note "Python: present (${found#* })"; return; fi

    # Best-effort, like .NET: never abort the installer on a Python failure.
    local ok_install=1
    if is_macos; then
        if [[ -n "$BREW" ]]; then run "$BREW" install "python@${PYTHON_DEFAULT}" || ok_install=0
        else
            warn "Homebrew not found — skipping Python. Install ${PYTHON_DEFAULT} from https://www.python.org/downloads/"
            note "Python: skipped (no brew)"; return
        fi
    else
        local sudo=""; [[ "$(id -u)" -ne 0 ]] && have sudo && sudo="sudo"
        if   have apt-get; then
            run $sudo apt-get update || true
            run $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "python${PYTHON_DEFAULT}" python3-pip \
                || run $sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y python3 python3-pip \
                || ok_install=0
        elif have dnf;     then run $sudo dnf install -y python3 python3-pip || ok_install=0
        elif have yum;     then run $sudo yum install -y python3 python3-pip || ok_install=0
        elif have pacman;  then run $sudo pacman -Sy --noconfirm python python-pip || ok_install=0
        elif have apk;     then run $sudo apk add --no-cache python3 py3-pip || ok_install=0
        else
            warn "No supported package manager — skipping Python. Install from https://www.python.org/downloads/"
            note "Python: skipped (no package manager)"; return
        fi
    fi
    refresh_path
    if [[ $ok_install -eq 0 ]]; then
        warn "Python install did not complete — skipping. Install from https://www.python.org/downloads/"
        note "Python: skipped (install failed)"; return
    fi
    note "Python: installed"
}

# ── Run ───────────────────────────────────────────────────────────────────────
main() {
    step "UiPath CLI onboarding installer"
    info "© 2026 UiPath"
    [[ $DRY_RUN -eq 1 ]] && warn "dry-run: no changes will be made"
    info "Platform: $(uname -s) $(uname -m)"

    resolve_brew
    if is_macos && [[ -n "$BREW" ]]; then
        if [[ $BREW_ARCH_MATCH -eq 1 ]]; then info "Homebrew: $BREW"
        else warn "Homebrew: $BREW (does not match $(uname -m); the .NET installs will use the official installer)"; fi
    fi

    ensure_curl
    ensure_node
    ensure_cli
    ensure_skills
    ensure_dotnet
    ensure_dotnet_runtime
    ensure_python

    step "Done"
    for line in "${SUMMARY[@]}"; do info "$line"; done
    info "If a PATH entry was added, open a new shell (or source your rc) to pick it up."
    # Note: keep this an `if`, not `[[ ]] && warn`, so a false condition here
    # doesn't become the script's (non-zero) exit status.
    if [[ $DRY_RUN -eq 1 ]]; then
        warn "This was a dry-run. Re-run without --dry-run to apply."
    fi
}

main
