reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,715 @@
#!/usr/bin/env bash
# bootstrap-reverse.sh — Kali Linux 版自动安装/补齐工具
# 等价于 Windows 版的 bootstrap-reverse.ps1
#
# 用法:
# bash bootstrap-reverse.sh <capability1> [capability2] ... [--start-services] [--skip-refresh]
#
# 示例:
# bash bootstrap-reverse.sh jadx apktool frida
# bash bootstrap-reverse.sh idapro --start-services
# bash bootstrap-reverse.sh jshookmcp anything-analyzer
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
KALI_MANIFEST="$SCRIPT_DIR/bootstrap-manifest.json"
source "$SCRIPT_DIR/lib/tool-discovery.sh"
# ─── 参数解析 ──────────────────────────────────────────────────────────────────────
CAPABILITIES=()
START_SERVICES=false
SKIP_REFRESH=false
MANUAL_REQUIRED=false
LAST_CAPABILITY_MANUAL=false
for arg in "$@"; do
case "$arg" in
--start-services) START_SERVICES=true ;;
--skip-refresh) SKIP_REFRESH=true ;;
--list|-l)
echo "jadx apktool jeb-pro frida frida-ps idalib-mcp jshookmcp reqable-mcp anything-analyzer idapro r2 rabin2 adb agent-browser ghidra-mcp seclists proxycat burpsuite-mcp nmap pentestswarm bkcrack"
echo "mcp-kali-server metasploitmcp hexstrike-ai adaptixc2 atomic-operator sstimap xsstrike wpprobe fluxion gef coercer evil-winrm-py netexec responder bloodhound certipy"
exit 0
;;
-*) echo "未知选项: $arg"; exit 1 ;;
*) CAPABILITIES+=("$arg") ;;
esac
done
if [[ ${#CAPABILITIES[@]} -eq 0 ]]; then
echo "用法: $0 <capability1> [capability2] ... [--start-services] [--skip-refresh]"
echo ""
echo "可用能力:"
echo ""
echo " [逆向分析]"
echo " jadx apktool jeb-pro frida frida-ps idalib-mcp r2 rabin2 adb gef"
echo ""
echo " [渗透测试 - 经典工具]"
echo " nmap sqlmap hashcat hydra gobuster ffuf msfconsole nuclei"
echo " netexec responder crackmapexec bloodhound certipy wfuzz"
echo " aircrack-ng coercer evil-winrm-py"
echo ""
echo " [渗透测试 - Kali 2026.1 新增]"
echo " adaptixc2 atomic-operator sstimap xsstrike wpprobe fluxion"
echo ""
echo " [MCP 服务]"
echo " jshookmcp reqable-mcp anything-analyzer idapro agent-browser"
echo " mcp-kali-server metasploitmcp hexstrike-ai pentestswarm"
echo ""
echo " [CTF 压缩包]"
echo " bkcrack"
echo ""
echo " [其他]"
echo " ghidra-mcp seclists proxycat burpsuite-mcp"
echo ""
echo "示例:"
echo " $0 mcp-kali-server metasploitmcp hexstrike-ai pentestswarm # 全部渗透 MCP"
echo " $0 adaptixc2 sstimap xsstrike wpprobe # 安装 2026.1 新工具"
echo " $0 pentestswarm --start-services # 安装 Swarm AI"
echo " $0 idapro --start-services # 安装并启动 IDA MCP"
exit 1
fi
# ─── 辅助函数 ──────────────────────────────────────────────────────────────────────
log_info() { echo -e "\033[36m[INFO]\033[0m $*"; }
log_ok() { echo -e "\033[32m[OK]\033[0m $*"; }
log_warn() { echo -e "\033[33m[WARN]\033[0m $*"; }
log_err() { echo -e "\033[31m[ERR]\033[0m $*"; }
# 检查是否有 sudo 权限
check_sudo() {
if [[ $EUID -eq 0 ]]; then
return 0
fi
if sudo -n true 2>/dev/null; then
return 0
fi
log_warn "部分操作需要 sudo 权限"
return 1
}
# apt 安装
install_apt_package() {
local package="$1"
log_info "apt install $package ..."
if [[ $EUID -eq 0 ]]; then
apt-get update -qq && apt-get install -y -qq "$package"
else
sudo apt-get update -qq && sudo apt-get install -y -qq "$package"
fi
}
# pip 安装
install_pip_package() {
local package="$1"
local source="${2:-}"
local target="${source:-$package}"
log_info "pip3 install $target ..."
pip3 install --upgrade "$target" --break-system-packages 2>/dev/null \
|| pip3 install --upgrade "$target"
}
# npm 全局安装
install_npm_global() {
local package="$1"
log_info "npm install -g $package ..."
if [[ $EUID -eq 0 ]]; then
npm install -g "$package"
else
sudo npm install -g "$package" 2>/dev/null || npm install -g "$package"
fi
}
# Git clone at an immutable commit. Existing mismatched checkouts are rejected
# instead of being overwritten, so local operator changes are never discarded.
install_git_commit() {
local repo="$1"
local commit="$2"
local install_dir="$3"
if [[ -d "$install_dir/.git" ]]; then
local current
current=$(git -C "$install_dir" rev-parse HEAD 2>/dev/null || true)
if [[ "$current" != "$commit" ]]; then
log_err "Existing checkout is not at pinned commit $commit: $install_dir"
log_err "Move it aside explicitly, then retry; bootstrap will not overwrite local changes."
return 1
fi
return 0
fi
if [[ -e "$install_dir" ]]; then
log_err "Install path exists but is not a git checkout: $install_dir"
return 1
fi
mkdir -p "$(dirname "$install_dir")"
git init -q "$install_dir"
git -C "$install_dir" remote add origin "$repo"
git -C "$install_dir" fetch --depth 1 origin "$commit"
git -C "$install_dir" checkout -q --detach FETCH_HEAD
local resolved
resolved=$(git -C "$install_dir" rev-parse HEAD)
if [[ "$resolved" != "$commit" ]]; then
log_err "Pinned checkout verification failed (expected $commit, got $resolved)"
return 1
fi
}
# GitHub Release 下载并解压。
# Args: repo asset_regex install_dir [release_tag] [expected_sha256]
install_github_release() {
local repo="$1"
local asset_regex="$2"
local install_dir="$3"
local release_tag="${4:-}"
local expected_sha256="${5:-}"
if ! command -v jq &>/dev/null; then
log_err "jq is required to select and verify GitHub release assets"
return 1
fi
log_info "从 GitHub Release 下载: $repo ..."
local api_url
if [[ -n "$release_tag" ]]; then
api_url="https://api.github.com/repos/${repo}/releases/tags/${release_tag}"
else
api_url="https://api.github.com/repos/${repo}/releases/latest"
fi
local release_json
release_json=$(curl --fail --silent --show-error --location "$api_url")
local asset
asset=$(printf '%s' "$release_json" | jq -cer --arg regex "$asset_regex" '.assets[] | select(.name | test($regex)) | {name, browser_download_url, digest}' | head -n1)
if [[ -z "$asset" || "$asset" == "null" ]]; then
log_err "未找到匹配 $asset_regex 的 release asset (tag=${release_tag:-latest})"
return 1
fi
local download_url filename api_digest
download_url=$(printf '%s' "$asset" | jq -r '.browser_download_url')
filename=$(printf '%s' "$asset" | jq -r '.name')
api_digest=$(printf '%s' "$asset" | jq -r '.digest // empty')
local tmp_file
tmp_file=$(mktemp "/tmp/reverse-bootstrap-${filename}.XXXXXX")
local tmp_extract=''
cleanup_github_release() {
rm -f "$tmp_file"
if [[ -n "$tmp_extract" ]]; then rm -rf "$tmp_extract"; fi
}
log_info "下载: $download_url"
if ! curl --fail --silent --show-error --location -o "$tmp_file" "$download_url"; then
cleanup_github_release
return 1
fi
local expected="${expected_sha256#sha256:}"
expected="${expected,,}"
if [[ -z "$expected" && -n "$api_digest" ]]; then
expected="${api_digest#sha256:}"
expected="${expected,,}"
fi
if [[ -z "$expected" ]]; then
log_err "缺少固定 SHA-256 或 GitHub digest,拒绝安装未校验资产: $filename"
cleanup_github_release
return 1
fi
local actual
actual=$(sha256sum "$tmp_file" | awk '{print tolower($1)}')
if [[ "$actual" != "$expected" ]]; then
log_err "SHA-256 不匹配: $filename (expected $expected, got $actual)"
cleanup_github_release
return 1
fi
log_ok "SHA-256 校验通过: $actual"
# 创建安装目录
mkdir -p "$install_dir"
# 根据文件类型解压
case "$filename" in
*.tar.gz|*.tgz)
tar -xzf "$tmp_file" -C "$install_dir" --strip-components=1 2>/dev/null \
|| tar -xzf "$tmp_file" -C "$install_dir"
;;
*.zip)
tmp_extract=$(mktemp -d /tmp/reverse-bootstrap-extract.XXXXXX)
unzip -qo "$tmp_file" -d "$tmp_extract"
# 如果只有一个顶层目录,strip 它
local top_dirs
top_dirs=$(find "$tmp_extract" -maxdepth 1 -mindepth 1 -type d)
if [[ $(printf '%s\n' "$top_dirs" | wc -l) -eq 1 ]]; then
cp -a "$top_dirs"/. "$install_dir/"
else
cp -a "$tmp_extract"/. "$install_dir/"
fi
;;
*.deb)
if [[ $EUID -eq 0 ]]; then
dpkg -i "$tmp_file" || apt-get install -f -y
else
sudo dpkg -i "$tmp_file" || sudo apt-get install -f -y
fi
;;
*)
cp "$tmp_file" "$install_dir/"
;;
esac
cleanup_github_release
# 把 bin 目录加入 PATH(当前 session)
if [[ -d "$install_dir/bin" ]]; then
export PATH="$install_dir/bin:$PATH"
else
export PATH="$install_dir:$PATH"
fi
log_ok "已安装到 $install_dir"
}
# 注册 MCP server 到 Claude 配置
register_mcp_server() {
local server_name="$1"
local config_json="$2" # JSON 格式的 server 配置
local config_path
config_path=$(get_claude_mcp_config_path)
local config_dir
config_dir=$(dirname "$config_path")
mkdir -p "$config_dir"
if [[ ! -f "$config_path" ]]; then
echo '{"mcpServers":{}}' > "$config_path"
fi
if command -v jq &>/dev/null; then
local tmp_file="/tmp/mcp-config-$$.json"
jq ".mcpServers.\"${server_name}\" = ${config_json}" "$config_path" > "$tmp_file"
mv "$tmp_file" "$config_path"
log_ok "MCP server '$server_name' 已注册到 $config_path"
else
log_warn "未安装 jq,无法自动注册 MCP server。请手动编辑 $config_path"
fi
}
# 等待端口就绪
wait_for_port() {
local port="$1"
local timeout="${2:-90}"
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
if test_tcp_port "$port" 2>/dev/null; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
# ─── 能力安装逻辑 ──────────────────────────────────────────────────────────────────
manifest_field() {
local capability="$1"
local field="$2"
if [[ ! -f "$KALI_MANIFEST" ]] || ! command -v jq &>/dev/null; then
return 1
fi
jq -er --arg name "$capability" --arg field "$field" \
'.capabilities[] | select(.name == $name) | .[$field] // empty' "$KALI_MANIFEST"
}
install_manifest_release() {
local capability="$1"
local repo asset_regex install_dir release_tag asset_sha256
repo=$(manifest_field "$capability" repo) || {
log_err "manifest 中缺少 $capability.repo"
return 1
}
asset_regex=$(manifest_field "$capability" assetRegex) || {
log_err "manifest 中缺少 $capability.assetRegex"
return 1
}
install_dir=$(manifest_field "$capability" installDir) || {
log_err "manifest 中缺少 $capability.installDir"
return 1
}
release_tag=$(manifest_field "$capability" releaseTag) || {
log_err "manifest 中缺少 $capability.releaseTag;拒绝使用 latest"
return 1
}
asset_sha256=$(manifest_field "$capability" assetSha256) || {
log_err "manifest 中缺少 $capability.assetSha256;拒绝下载未固定资产"
return 1
}
install_dir="${install_dir/\$HOME/$HOME}"
install_github_release "$repo" "$asset_regex" "$install_dir" "$release_tag" "$asset_sha256"
}
ensure_capability() {
local name="$1"
# 先检查是否已可用
if command -v "$name" &>/dev/null; then
log_ok "$name 已可用: $(command -v "$name")"
return 0
fi
log_info "开始安装: $name"
case "$name" in
# ─── apt 预装/可装的工具 ───
nmap|sqlmap|hashcat|hydra|gobuster|ffuf|adb|bkcrack)
install_apt_package "$name"
;;
msfconsole)
install_apt_package "metasploit-framework"
;;
r2|rabin2|rasm2|radiff2|rahash2|rax2)
if ! command -v r2 &>/dev/null; then
install_apt_package "radare2"
fi
;;
apktool)
install_apt_package "apktool"
;;
seclists)
install_apt_package "seclists"
;;
# ─── Kali 2026.1 新增工具(全部 apt 直装) ───
adaptixc2)
install_apt_package "adaptixc2"
;;
atomic-operator)
install_apt_package "atomic-operator"
;;
fluxion)
install_apt_package "fluxion"
;;
gef)
install_apt_package "gef"
log_info "GEF 已安装。启动 gdb 时自动加载 GEF 增强功能。"
;;
sstimap)
install_apt_package "sstimap"
;;
xsstrike)
install_apt_package "xsstrike"
;;
wpprobe)
install_apt_package "wpprobe"
;;
evil-winrm-py)
install_apt_package "evil-winrm-py"
;;
coercer)
install_apt_package "coercer"
;;
netexec)
install_apt_package "netexec"
;;
responder)
install_apt_package "responder"
;;
crackmapexec)
install_apt_package "crackmapexec"
;;
bloodhound)
install_apt_package "bloodhound"
;;
certipy)
install_apt_package "certipy-ad"
;;
wfuzz)
install_apt_package "wfuzz"
;;
aircrack-ng)
install_apt_package "aircrack-ng"
;;
# ─── Kali 原生 MCP 工具(apt 安装 + MCP 注册) ───
mcp-kali-server)
install_apt_package "mcp-kali-server"
register_mcp_server "kali-server" '{
"command": "kali-server-mcp",
"args": ["--port", "5000"]
}'
log_info "启动方式: kali-server-mcp --port 5000"
log_info "然后用 mcp-server 连接 AI 客户端到 API server"
;;
metasploitmcp)
install_apt_package "metasploitmcp"
register_mcp_server "metasploit-mcp" '{
"command": "metasploitmcp",
"args": ["--transport", "stdio"]
}'
log_info "MetasploitMCP 支持 stdio 和 HTTP 两种模式"
log_info " stdio: metasploitmcp --transport stdio"
log_info " HTTP: metasploitmcp --transport http --port 8085"
;;
hexstrike-ai)
install_apt_package "hexstrike-ai"
register_mcp_server "hexstrike" '{
"command": "hexstrike-ai",
"args": []
}'
log_info "HexStrike AI 已安装。150+ 安全工具通过 MCP 暴露给 AI agent。"
;;
# ─── Pentest Swarm AI(群体智能渗透框架) ───
pentestswarm)
if command -v pentestswarm &>/dev/null; then
log_ok "pentestswarm 已可用"
elif command -v go &>/dev/null; then
log_info "go install pentestswarm ..."
go install github.com/Armur-Ai/Pentest-Swarm-AI/cmd/pentestswarm@v0.1.0
elif command -v docker &>/dev/null; then
log_info "拉取 pentestswarm Docker 镜像 ..."
docker pull ghcr.io/armur-ai/pentestswarm:v0.1.0
log_info "使用方式: docker run --rm ghcr.io/armur-ai/pentestswarm:v0.1.0 scan <target> --scope <scope>"
else
log_warn "需要 Go 1.24+ 或 Docker 来安装 pentestswarm"
log_info "安装 Go: apt install golang-go"
log_info "然后: go install github.com/Armur-Ai/Pentest-Swarm-AI/cmd/pentestswarm@v0.1.0"
return 1
fi
register_mcp_server "pentestswarm" '{
"command": "pentestswarm",
"args": ["mcp", "serve"]
}'
log_info "Pentest Swarm AI 已配置"
log_info " MCP 模式: pentestswarm mcp serve"
log_info " 扫描模式: pentestswarm scan <target> --scope <scope> --swarm"
log_info " 需要设置: export PENTESTSWARM_ORCHESTRATOR_API_KEY=<your-claude-key>"
;;
# ─── pip 安装 ───
frida|frida-ps)
install_pip_package "frida-tools==14.10.4"
;;
idalib-mcp)
install_pip_package "ida-pro-mcp" "git+https://github.com/mrexodia/ida-pro-mcp.git@f82e6e2517a161b77e738951c3071cd446480ba0"
log_info "运行 ida-pro-mcp --install 完成 IDA 插件安装"
;;
proxycat)
local proxycat_dir="$HOME/tools/ProxyCat"
install_git_commit \
"https://github.com/honmashironeko/ProxyCat.git" \
"2309b713e2e4f574df14c2ace7e8fa6c00eb6941" \
"$proxycat_dir"
pip3 install --upgrade -r "$proxycat_dir/requirements.txt" --break-system-packages 2>/dev/null \
|| pip3 install --upgrade -r "$proxycat_dir/requirements.txt"
local proxycat_bin_dir="$HOME/.local/bin"
local proxycat_wrapper="$proxycat_bin_dir/proxycat"
mkdir -p "$proxycat_bin_dir"
cat > "$proxycat_wrapper" <<EOF
#!/usr/bin/env bash
exec python3 "$proxycat_dir/ProxyCat.py" "\$@"
EOF
chmod 0755 "$proxycat_wrapper"
log_info "ProxyCat installed at pinned commit; command wrapper: $proxycat_wrapper"
if [[ ":$PATH:" != *":$proxycat_bin_dir:"* ]]; then
log_warn "Add $proxycat_bin_dir to PATH before using the proxycat command."
fi
;;
pwntools)
install_pip_package "pwntools==4.15.0"
;;
# ─── GitHub Release ───
jadx)
install_manifest_release "jadx"
chmod +x "$HOME/tools/jadx/bin/jadx" 2>/dev/null || true
;;
ghidra-mcp)
if command -v ghidra &>/dev/null; then
log_ok "ghidra 已通过 apt 安装"
else
install_apt_package "ghidra" 2>/dev/null \
|| install_github_release "NationalSecurityAgency/ghidra" "^ghidra_.*_PUBLIC_.*\\.zip$" "$HOME/tools/ghidra"
fi
log_warn "GhidraMCP 插件需手动安装: https://github.com/LaurieWired/GhidraMCP/releases"
;;
nuclei)
if command -v go &>/dev/null; then
log_info "go install nuclei ..."
go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@v3.8.0
else
install_github_release "projectdiscovery/nuclei" "^nuclei_.*_linux_amd64\\.zip$" "$HOME/tools/nuclei" "v3.8.0"
fi
;;
# ─── npm/MCP ───
jeb-pro)
log_warn "MANUAL_INSTALL_REQUIRED: jeb-pro"
log_warn "JEB Pro 是商业工具;请从 PNF Software 获取有效许可证后手动安装。社区 MCP 桥接必须先按 skill-supply-chain.md 审查。"
LAST_CAPABILITY_MANUAL=true
MANUAL_REQUIRED=true
return 0
;;
reqable-mcp)
if ! command -v node &>/dev/null; then
install_apt_package "nodejs"
fi
if ! command -v npm &>/dev/null; then
install_apt_package "npm"
fi
register_mcp_server "reqable-mcp" '{
"command": "npx",
"args": ["-y", "reqable-mcp-server@1.0.1", "--scope", "minimal"]
}'
log_warn "Reqable MCP 需要单独安装 Reqable 桌面客户端并启用其本地 API。"
;;
jshookmcp)
if ! command -v node &>/dev/null; then
install_apt_package "nodejs"
fi
if ! command -v npm &>/dev/null; then
install_apt_package "npm"
fi
register_mcp_server "jshook" '{
"command": "npx",
"args": ["-y", "@jshookmcp/jshook@0.3.4"],
"env": {"JSHOOK_BASE_PROFILE": "search"}
}'
;;
agent-browser)
if ! command -v node &>/dev/null; then
install_apt_package "nodejs"
fi
install_npm_global "agent-browser@0.31.1"
npx playwright install chromium 2>/dev/null || true
;;
# ─── 本地 HTTP MCP 服务 ───
anything-analyzer)
register_mcp_server "anything-analyzer" "{\"url\": \"http://localhost:23816/mcp\"}"
if [[ "$START_SERVICES" == "true" ]]; then
start_anything_analyzer
fi
;;
idapro)
# 先确保 idalib-mcp 已安装
ensure_capability "idalib-mcp"
register_mcp_server "idapro" "{\"url\": \"http://127.0.0.1:13337/mcp\"}"
if [[ "$START_SERVICES" == "true" ]]; then
start_idapro_service
fi
;;
# ─── 手动安装 ───
burpsuite-mcp)
log_warn "MANUAL_INSTALL_REQUIRED: burpsuite-mcp"
log_warn "Kali 预装 BurpSuite,在扩展市场搜索 MCP 插件安装"
register_mcp_server "burpsuite" "{\"url\": \"http://localhost:9876/mcp\"}"
;;
*)
log_err "未知能力: $name"
return 1
;;
esac
}
# ─── 服务启动 ──────────────────────────────────────────────────────────────────────
start_anything_analyzer() {
if test_tcp_port 23816 2>/dev/null; then
log_ok "anything-analyzer 已在运行 (port 23816)"
return 0
fi
local repo_dir="$HOME/tools/anything-analyzer"
if [[ ! -d "$repo_dir" ]]; then
log_info "克隆 anything-analyzer ..."
git clone https://github.com/Mouseww/anything-analyzer.git "$repo_dir"
fi
if ! command -v pnpm &>/dev/null; then
npm install -g pnpm
fi
(cd "$repo_dir" && pnpm install && nohup pnpm dev > /tmp/anything-analyzer.log 2>&1 &)
log_info "等待 anything-analyzer 启动 (port 23816) ..."
if wait_for_port 23816 120; then
log_ok "anything-analyzer 已启动"
else
log_err "anything-analyzer 启动超时,查看日志: /tmp/anything-analyzer.log"
return 1
fi
}
start_idapro_service() {
if test_tcp_port 13337 2>/dev/null; then
log_ok "IDA Pro MCP 已在运行 (port 13337)"
return 0
fi
local ida_start_script="$SCRIPT_DIR/ida-start.sh"
if [[ -x "$ida_start_script" ]]; then
bash "$ida_start_script"
else
log_warn "IDA 启动脚本不存在: $ida_start_script"
log_warn "请手动启动 IDA Pro,插件会自动监听 13337 端口"
return 1
fi
}
# ─── 主流程 ────────────────────────────────────────────────────────────────────────
RESULTS=()
for cap in "${CAPABILITIES[@]}"; do
LAST_CAPABILITY_MANUAL=false
if ensure_capability "$cap"; then
if [[ "$LAST_CAPABILITY_MANUAL" == "true" ]]; then
RESULTS+=("{\"name\":\"$cap\",\"status\":\"manual-required\"}")
else
RESULTS+=("{\"name\":\"$cap\",\"status\":\"ready\"}")
fi
else
RESULTS+=("{\"name\":\"$cap\",\"status\":\"failed\"}")
fi
done
# 刷新工具索引
if [[ "$SKIP_REFRESH" != "true" ]]; then
log_info "刷新工具索引 ..."
bash "$SCRIPT_DIR/refresh-tool-index.sh" >/dev/null 2>&1 || true
fi
final_exit_code=0
if [[ "$MANUAL_REQUIRED" == "true" ]]; then
final_exit_code=2
fi
# 输出结果
echo ""
echo "═══════════════════════════════════════════"
echo " Bootstrap 完成"
echo "═══════════════════════════════════════════"
for r in "${RESULTS[@]}"; do
name=$(echo "$r" | jq -r '.name' 2>/dev/null || echo "$r")
status=$(echo "$r" | jq -r '.status' 2>/dev/null || echo "unknown")
if [[ "$status" == "ready" ]]; then
echo " ✓ $name"
elif [[ "$status" == "manual-required" ]]; then
echo " ! $name (manual install required)"
else
echo " ✗ $name (failed)"
fi
done
echo ""
exit "$final_exit_code"