1135 lines
39 KiB
Bash
Executable File
1135 lines
39 KiB
Bash
Executable File
#!/bin/bash
|
||
set -e
|
||
|
||
# ============================================================================
|
||
# 镜像资源预下载脚本
|
||
# 用于提前下载 Dockerfile 构建时需要的网络资源
|
||
# ============================================================================
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
CACHE_DIR="${SCRIPT_DIR}/.cache"
|
||
VOLUMES_DIR="${SCRIPT_DIR}/.volumes"
|
||
mkdir -p "${CACHE_DIR}" "${VOLUMES_DIR}"
|
||
|
||
# 引入公共函数库
|
||
# shellcheck source=lib.sh
|
||
source "${SCRIPT_DIR}/lib.sh"
|
||
source "${SCRIPT_DIR}/.env"
|
||
WORKSPACE="/home/vscode/$PROJECT_NAME"
|
||
git config user.name "${GIT_USER_NAME}"
|
||
git config user.email "${GIT_USER_EMAIL}"
|
||
git config core.autocrlf input
|
||
git config core.fileMode false
|
||
git config init.defaultBranch trunk
|
||
git config --add safe.directory "$WORKSPACE"
|
||
|
||
print_header "镜像资源预下载脚本"
|
||
|
||
echo "$(text_yellow '⚠️ 启动容器前提示:')"
|
||
echo " 1. 确保已创建 .env 文件: cp .env.example .env"
|
||
echo " 2. 根据需要修改 .env 中的配置(特别是 DOCKER_GID)"
|
||
echo ""
|
||
echo "开始下载镜像构建资源..."
|
||
echo "缓存目录: $(text_cyan "${CACHE_DIR}")"
|
||
echo ""
|
||
|
||
# ============================================================================
|
||
# 配置
|
||
# ============================================================================
|
||
|
||
# 版本配置(必须通过 .env 设置)
|
||
for _var in UV_VERSION NVM_VERSION NODE_VERSION NPM_VERSION CHROME_VERSION PYTHON_VERSION JJ_VERSION; do
|
||
if [ -z "${!_var:-}" ]; then
|
||
echo "错误: 缺少必需的环境变量 $_var,请在 .env 中设置"
|
||
exit 1
|
||
fi
|
||
done
|
||
ELECTRON_VERSION="${ELECTRON_VERSION:-}"
|
||
|
||
# Claude Code 版本锁定(参考 .cache/claude-bin/claude-install.sh)
|
||
CLAUDE_VERSION="2.1.112"
|
||
|
||
# 架构配置
|
||
UV_ARCH="x86_64-unknown-linux-gnu"
|
||
NODE_ARCH="linux-x64"
|
||
PYTHON_ARCH="x86_64-unknown-linux-gnu"
|
||
ELECTRON_ARCH="linux-x64"
|
||
JJ_ARCH="x86_64-unknown-linux-musl"
|
||
|
||
# 镜像源配置
|
||
NPM_REGISTRY="https://registry.npmmirror.com"
|
||
NPM_REGISTRY_OFFICIAL="https://registry.npmjs.org"
|
||
NODE_MIRROR="https://npmmirror.com/mirrors/node"
|
||
ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron"
|
||
|
||
echo "Python 版本: $(text_cyan "${PYTHON_VERSION}") (从南大镜像自动获取最新版本)"
|
||
echo ""
|
||
|
||
# npm 全局包列表(bun 必须在 opencode-ai 之前安装)
|
||
NPM_PACKAGES=(
|
||
"npm@${NPM_VERSION}"
|
||
"yarn"
|
||
"tsx"
|
||
"@anthropic-ai/claude-code@${CLAUDE_VERSION}"
|
||
"chrome-devtools-mcp"
|
||
"@playwright/mcp"
|
||
"pyright"
|
||
"uipro-cli"
|
||
"bun"
|
||
|
||
"opencode-ai"
|
||
"@z_ai/coding-helper"
|
||
"svelte-language-server"
|
||
"typescript"
|
||
"typescript-language-server"
|
||
"tsgo-dev"
|
||
)
|
||
|
||
# VSCode 扩展列表
|
||
VSCODE_EXTENSIONS=(
|
||
"anthropic.claude-code@${CLAUDE_VERSION}"
|
||
"bierner.markdown-mermaid"
|
||
"charliermarsh.ruff"
|
||
"dbaeumer.vscode-eslint"
|
||
"github.copilot"
|
||
"github.copilot-chat"
|
||
"humao.rest-client"
|
||
"james-yu.latex-workshop"
|
||
"marp-team.marp-vscode"
|
||
"ms-azuretools.vscode-containers"
|
||
"ms-azuretools.vscode-docker"
|
||
"ms-ceintl.vscode-language-pack-zh-hans"
|
||
"ms-python.autopep8"
|
||
"ms-python.debugpy"
|
||
"ms-python.python"
|
||
"ms-python.vscode-pylance"
|
||
"ms-python.vscode-python-envs"
|
||
"ms-playwright.playwright"
|
||
"ms-vscode-remote.remote-containers"
|
||
"ms-vscode-remote.remote-ssh"
|
||
"mutantdino.resourcemonitor"
|
||
"redhat.vscode-yaml"
|
||
"shd101wyy.markdown-preview-enhanced"
|
||
"sst-dev.opencode"
|
||
"svelte.svelte-vscode"
|
||
"tamasfe.even-better-toml"
|
||
"yzhang.markdown-all-in-one"
|
||
"jjk.jjk"
|
||
)
|
||
|
||
# ============================================================================
|
||
# 通用函数
|
||
# ============================================================================
|
||
|
||
create_cache_dirs() {
|
||
local dirs=("node" "npm" "spec-kit" "uv" "vscode" "chrome" "nvm" "electron" "jj" "claude-plugins" "claude-bin")
|
||
for dir in "${dirs[@]}"; do
|
||
mkdir -p "${CACHE_DIR}/${dir}"
|
||
done
|
||
}
|
||
|
||
create_volumes_dirs() {
|
||
local dirs=("bin" "claude" "jj" "ssh")
|
||
for dir in "${dirs[@]}"; do
|
||
mkdir -p "${VOLUMES_DIR}/${dir}"
|
||
done
|
||
}
|
||
|
||
# 保存文件校验和到缓存目录(并发安全)
|
||
save_checksum() {
|
||
local file=$1
|
||
local checksum_file="${CACHE_DIR}/.checksums.txt"
|
||
|
||
if [ -f "${file}" ]; then
|
||
local checksum
|
||
checksum=$(compute_sha256 "${file}")
|
||
local rel_path
|
||
rel_path=$(realpath --relative-to="${CACHE_DIR}" "${file}" 2>/dev/null || echo "${file}")
|
||
|
||
# 使用文件锁确保并发安全
|
||
(
|
||
flock -x 200 || exit 1
|
||
|
||
# 移除旧的校验和记录(如果存在)
|
||
if [ -f "${checksum_file}" ]; then
|
||
grep -v "|${rel_path}$" "${checksum_file}" > "${checksum_file}.tmp" 2>/dev/null || true
|
||
mv "${checksum_file}.tmp" "${checksum_file}" 2>/dev/null || true
|
||
fi
|
||
|
||
# 追加新的校验和
|
||
echo "${checksum}|${rel_path}" >> "${checksum_file}"
|
||
|
||
) 200>"${checksum_file}.lock"
|
||
fi
|
||
}
|
||
|
||
# 从缓存验证所有文件的校验和
|
||
verify_all_checksums() {
|
||
local checksum_file="${CACHE_DIR}/.checksums.txt"
|
||
local failed=0
|
||
|
||
if [ ! -f "${checksum_file}" ]; then
|
||
printf " %s 未找到校验和文件,跳过验证\n" "$(status_info)"
|
||
return 0
|
||
fi
|
||
|
||
printf " %s 验证文件完整性...\n" "$(status_info)"
|
||
while IFS='|' read -r checksum rel_path; do
|
||
local file="${CACHE_DIR}/${rel_path}"
|
||
if [ -f "${file}" ]; then
|
||
local actual_checksum
|
||
actual_checksum=$(compute_sha256 "${file}")
|
||
if [ "${actual_checksum}" != "${checksum}" ]; then
|
||
printf " %s %s: 校验和不匹配\n" "$(status_fail)" "${rel_path}"
|
||
((failed++))
|
||
fi
|
||
fi
|
||
done < "${checksum_file}"
|
||
|
||
if [ ${failed} -eq 0 ]; then
|
||
printf " %s 所有文件校验通过\n" "$(status_ok)"
|
||
return 0
|
||
else
|
||
printf " %s %s 个文件校验失败\n" "$(status_fail)" "${failed}"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# 通用下载函数
|
||
# ============================================================================
|
||
|
||
# 下载带校验和的二进制文件
|
||
# 参数: name, url, output_file, checksum_url, checksum_filename, min_size
|
||
download_binary_with_checksum() {
|
||
local name=$1
|
||
local url=$2
|
||
local output_file=$3
|
||
local checksum_url=$4
|
||
local checksum_filename=$5
|
||
local min_size=${6:-1024}
|
||
|
||
# 获取校验和
|
||
local expected_checksum=""
|
||
if [ -n "${checksum_url}" ] && [ -n "${checksum_filename}" ]; then
|
||
expected_checksum=$(get_shasums256_checksum "${checksum_url}" "${checksum_filename}")
|
||
if [ -z "${expected_checksum}" ]; then
|
||
printf " %s 无法获取校验和,将跳过验证\n" "$(status_warn)"
|
||
fi
|
||
fi
|
||
|
||
# 使用现有的 download_file 函数
|
||
download_file \
|
||
"${url}" \
|
||
"${output_file}" \
|
||
"${name}" \
|
||
"${expected_checksum}" \
|
||
"${min_size}"
|
||
}
|
||
|
||
# ============================================================================
|
||
# 资源下载函数
|
||
# ============================================================================
|
||
|
||
download_nvm() {
|
||
echo "[1/9] NVM 仓库"
|
||
clone_repo \
|
||
"https://gitea.szis.com.cn/github/nvm-sh-nvm.git" \
|
||
"${CACHE_DIR}/nvm" \
|
||
"${NVM_VERSION}" \
|
||
"NVM ${NVM_VERSION}"
|
||
}
|
||
|
||
download_uv_binary() {
|
||
echo "[2/8] UV 二进制文件"
|
||
|
||
local uv_file="uv-${UV_ARCH}.tar.gz"
|
||
|
||
download_file \
|
||
"https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${uv_file}" \
|
||
"${CACHE_DIR}/uv/${uv_file}" \
|
||
"UV ${UV_VERSION}" \
|
||
"" \
|
||
"1048576" # 最小 1MB
|
||
}
|
||
|
||
download_python() {
|
||
echo "[3/8] Python ${PYTHON_VERSION} 预编译二进制文件"
|
||
|
||
local PYTHON_MIRROR="https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/LatestRelease/"
|
||
|
||
# 从镜像动态获取最新的 x86_64-unknown-linux-gnu install_only 版本
|
||
printf " %s 从南大镜像获取最新 Python %s 版本信息...\n" "$(status_info)" "${PYTHON_VERSION}"
|
||
local python_file
|
||
python_file=$(wget -qO- "${PYTHON_MIRROR}" \
|
||
| grep -oP "cpython-${PYTHON_VERSION}\.[0-9]+\+[0-9]+-x86_64-unknown-linux-gnu-install_only\.tar\.gz" \
|
||
| head -1)
|
||
|
||
if [ -z "${python_file}" ]; then
|
||
printf " %s 无法从镜像获取 Python %s 版本信息\n" "$(status_fail)" "${PYTHON_VERSION}"
|
||
return 1
|
||
fi
|
||
|
||
# 解析完整版本号和构建日期
|
||
local python_full_version
|
||
python_full_version=$(echo "${python_file}" | grep -oP "${PYTHON_VERSION}\.[0-9]+")
|
||
local python_build_date
|
||
python_build_date=$(echo "${python_file}" | grep -oP '\+[0-9]+' | tr -d '+')
|
||
printf " %s 最新版本: $(text_cyan "Python ${python_full_version}") (构建日期: $(text_cyan "${python_build_date}"))\n" "$(status_ok)"
|
||
|
||
download_file \
|
||
"${PYTHON_MIRROR}${python_file}" \
|
||
"${CACHE_DIR}/uv/${python_file}" \
|
||
"Python ${python_full_version} (${python_build_date})" \
|
||
"" \
|
||
"10485760" # 最小 10MB
|
||
}
|
||
|
||
download_nodejs() {
|
||
echo "[4/8] Node.js 二进制文件"
|
||
|
||
local node_file="node-v${NODE_VERSION}-${NODE_ARCH}.tar.xz"
|
||
local shasums_url="${NODE_MIRROR}/v${NODE_VERSION}/SHASUMS256.txt"
|
||
|
||
download_binary_with_checksum \
|
||
"Node.js ${NODE_VERSION}" \
|
||
"${NODE_MIRROR}/v${NODE_VERSION}/${node_file}" \
|
||
"${CACHE_DIR}/node/${node_file}" \
|
||
"${shasums_url}" \
|
||
"${node_file}" \
|
||
"20971520" # 最小 20MB
|
||
}
|
||
|
||
# 清理同名旧版本的 npm 包文件
|
||
cleanup_old_npm_packages() {
|
||
local cache_dir="${CACHE_DIR}/npm"
|
||
[ ! -d "${cache_dir}" ] && return 0
|
||
|
||
echo "清理同名旧版本的 npm 包..."
|
||
cd "${cache_dir}" || return 1
|
||
|
||
# 找出所有包名(去掉版本号)
|
||
local packages
|
||
packages=$(ls -1 *.tgz 2>/dev/null | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+.*\.tgz$//' | sort -u)
|
||
|
||
for pkg_name in ${packages}; do
|
||
# 统计同名包文件数量
|
||
local count
|
||
count=$(ls -1 ${pkg_name}-[0-9]*.tgz 2>/dev/null | wc -l)
|
||
|
||
if [ "${count}" -gt 1 ]; then
|
||
echo " 发现 $(text_yellow "${count}") 个 ${pkg_name} 版本,保留最新的..."
|
||
|
||
# 按文件修改时间排序,保留最新的,删除旧的
|
||
ls -t ${pkg_name}-[0-9]*.tgz 2>/dev/null | tail -n +2 | while read -r old_file; do
|
||
echo " 删除旧版本: $(text_gray "${old_file}")"
|
||
rm -f "${old_file}"
|
||
done
|
||
fi
|
||
done
|
||
|
||
echo " $(status_ok) 清理完成"
|
||
}
|
||
|
||
download_npm_packages() {
|
||
echo "[5/8] npm 全局包"
|
||
cd "${CACHE_DIR}/npm" || return 1
|
||
|
||
echo "请确保已经安装了 npm"
|
||
|
||
# 先清理同名旧版本
|
||
cleanup_old_npm_packages
|
||
|
||
for package in "${NPM_PACKAGES[@]}"; do
|
||
local package_name
|
||
local package_version=""
|
||
local file_package_name
|
||
|
||
# 处理包名和版本号
|
||
if [[ "${package}" == @*/*@* ]]; then
|
||
# scoped package with version: @scope/name@version
|
||
package_name="${package%@*}"
|
||
package_version="${package##*@}"
|
||
file_package_name="${package_name#@}"
|
||
file_package_name="${file_package_name//\//-}"
|
||
elif [[ "${package}" == @*/* ]]; then
|
||
# scoped package without version: @scope/name
|
||
package_name="${package}"
|
||
file_package_name="${package_name#@}"
|
||
file_package_name="${file_package_name//\//-}"
|
||
else
|
||
# regular package: name or name@version
|
||
package_name="${package%%@*}"
|
||
[[ "${package}" == *@* ]] && package_version="${package#*@}"
|
||
file_package_name="${package_name}"
|
||
fi
|
||
# 查找已存在的包文件(使用更可靠的 find 命令)
|
||
local existing_package
|
||
existing_package=$(find . -maxdepth 1 -type f -name "${file_package_name}-[0-9]*.tgz" 2>/dev/null | sort -V | head -n1 | sed 's|^\./||')
|
||
# 兼容:如果 find 没找到,回退到 ls
|
||
[ -z "${existing_package}" ] && existing_package=$(ls ${file_package_name}-[0-9]*.tgz 2>/dev/null | head -n1)
|
||
|
||
# 获取最新版本(无论文件是否存在)
|
||
local local_version=""
|
||
local latest_version=""
|
||
if [ -n "${existing_package}" ]; then
|
||
local_version=$(get_npm_package_version "${existing_package}")
|
||
fi
|
||
|
||
# 如果指定了版本,使用指定版本;否则获取最新版本
|
||
# 注意:如果指定的是 "latest",需要获取实际版本号进行比较
|
||
if [ -n "${package_version}" ]; then
|
||
if [ "${package_version}" = "latest" ]; then
|
||
latest_version=$(get_npm_latest_version "${package_name}" "${NPM_REGISTRY}")
|
||
else
|
||
latest_version="${package_version}"
|
||
fi
|
||
else
|
||
latest_version=$(get_npm_latest_version "${package_name}" "${NPM_REGISTRY}")
|
||
fi
|
||
|
||
# 删除所有同名旧版本的包(只保留最新版本)
|
||
if [ -n "${existing_package}" ]; then
|
||
# 删除除当前文件外的所有同名包(使用精确的文件名模式匹配)
|
||
# 使用 find 命令确保只匹配完全符合 "包名-数字.数字.数字" 格式的文件
|
||
for pkg_file in $(find . -maxdepth 1 -type f -name "${file_package_name}-[0-9]*.tgz" 2>/dev/null | sed 's|^\./||'); do
|
||
if [ "${pkg_file}" != "${existing_package}" ]; then
|
||
rm -f "${pkg_file}" 2>/dev/null || true
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# 如果包已存在,验证版本和完整性
|
||
if [ -n "${existing_package}" ]; then
|
||
|
||
# 验证文件格式(npm pack 生成的 .tgz 是 gzip 压缩的 tar 包)
|
||
# 优先使用 tar 测试,备用 file 命令
|
||
local is_valid=false
|
||
if command -v file >/dev/null 2>&1; then
|
||
file "${existing_package}" 2>/dev/null | grep -qE "(gzip|tar|POSIX)" && is_valid=true
|
||
else
|
||
# 如果 file 命令不可用,使用 tar 测试
|
||
tar -tzf "${existing_package}" >/dev/null 2>&1 && is_valid=true
|
||
fi
|
||
|
||
if [ "$is_valid" = false ]; then
|
||
print_status_fail "${package}" "文件损坏"
|
||
rm -f "${existing_package}"
|
||
# 确保文件被删除
|
||
[ -f "${existing_package}" ] && rm -rf "${existing_package}" 2>/dev/null || true
|
||
existing_package=""
|
||
else
|
||
# 检查版本
|
||
if [ -n "${local_version}" ] && [ -n "${latest_version}" ]; then
|
||
if [ "${local_version}" != "${latest_version}" ]; then
|
||
print_status_update "${package_name}" "${local_version}" "${latest_version}"
|
||
rm -f "${existing_package}"
|
||
existing_package=""
|
||
else
|
||
print_status_skip "${package_name}" "${local_version}"
|
||
save_checksum "${existing_package}"
|
||
fi
|
||
elif [ -n "${local_version}" ]; then
|
||
# 能获取本地版本但无法获取远程版本(网络问题?),保留本地文件
|
||
print_status_warn "${package_name} 无法获取远程版本信息 (v${local_version}),保留本地文件"
|
||
save_checksum "${existing_package}"
|
||
else
|
||
# 无法获取本地版本,重新下载
|
||
print_status_warn "${package_name} 无法解析本地版本,重新下载"
|
||
rm -f "${existing_package}"
|
||
existing_package=""
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# 确定要下载的版本(使用检测到的最新版本)
|
||
local download_package="${package}"
|
||
# 如果 package_version 是 "latest" 或未指定,使用获取到的实际版本号
|
||
if [ -z "${package_version}" ] && [ -n "${latest_version}" ]; then
|
||
download_package="${package_name}@${latest_version}"
|
||
elif [ "${package_version}" = "latest" ] && [ -n "${latest_version}" ]; then
|
||
download_package="${package_name}@${latest_version}"
|
||
fi
|
||
|
||
# 下载主包
|
||
if [ -z "${existing_package}" ]; then
|
||
print_status_get "${download_package}"
|
||
|
||
if npm pack "${download_package}" --registry "${NPM_REGISTRY}" >/dev/null 2>&1; then
|
||
# 验证下载的文件
|
||
local downloaded_package
|
||
downloaded_package=$(ls ${file_package_name}-*.tgz 2>/dev/null | head -n1)
|
||
|
||
if [ -n "${downloaded_package}" ] && [ -s "${downloaded_package}" ]; then
|
||
print_status_ok "${package_name}"
|
||
save_checksum "${downloaded_package}"
|
||
else
|
||
print_status_fail "${package_name}" "下载失败"
|
||
fi
|
||
else
|
||
print_status_fail "${package_name}" "npm pack 失败"
|
||
fi
|
||
fi
|
||
|
||
# @z_ai/coding-helper 需要验证 zai-coding-plugins 目录存在
|
||
if [ "${package_name}" = "@z_ai/coding-helper" ] && [ -n "${existing_package}" ]; then
|
||
if ! tar -tzf "${existing_package}" 2>/dev/null | grep -q "package/zai-coding-plugins/.claude-plugin/marketplace.json"; then
|
||
echo " $(status_warn) ${package_name} 缺少 zai-coding-plugins,重新下载"
|
||
rm -f "${existing_package}"
|
||
existing_package=""
|
||
fi
|
||
fi
|
||
|
||
# opencode-ai 需要平台特定的二进制包
|
||
if [ "${package_name}" = "opencode-ai" ]; then
|
||
local opencode_version="${package_version}"
|
||
|
||
# 确定版本号
|
||
if [ -z "${opencode_version}" ]; then
|
||
sleep 1
|
||
local main_package
|
||
main_package=$(ls opencode-ai-*.tgz 2>/dev/null | head -n1)
|
||
opencode_version=$(get_npm_package_version "${main_package}")
|
||
[ -z "${opencode_version}" ] && opencode_version=$(npm view opencode-ai version --registry "${NPM_REGISTRY}" 2>/dev/null | head -n1)
|
||
fi
|
||
|
||
if [ -n "${opencode_version}" ] && [ "${opencode_version}" != "opencode-ai" ]; then
|
||
local platform_pkg="opencode-linux-x64"
|
||
local platform_file="${platform_pkg}-${opencode_version}.tgz"
|
||
|
||
# 检查平台包是否已存在
|
||
if [ -f "${platform_file}" ] && [ -s "${platform_file}" ]; then
|
||
print_status_skip "${platform_pkg}@${opencode_version}"
|
||
else
|
||
print_status_get "${platform_pkg}@${opencode_version} (官方源)"
|
||
if npm pack "${platform_pkg}@${opencode_version}" --registry "${NPM_REGISTRY_OFFICIAL}" >/dev/null 2>&1; then
|
||
print_status_ok "${platform_pkg}"
|
||
save_checksum "${platform_file}"
|
||
else
|
||
print_status_fail "${platform_pkg}" "官方源下载失败"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
done
|
||
}
|
||
|
||
download_chrome() {
|
||
echo "[6/8] Google Chrome 浏览器"
|
||
download_file \
|
||
"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb" \
|
||
"${CACHE_DIR}/chrome/google-chrome-stable_${CHROME_VERSION}_amd64.deb" \
|
||
"Chrome ${CHROME_VERSION}" \
|
||
"" \
|
||
"104857600" # 最小 100MB
|
||
}
|
||
|
||
download_electron() {
|
||
echo "[7/8] Electron 二进制文件"
|
||
download_file \
|
||
"${ELECTRON_MIRROR}/${ELECTRON_VERSION}/electron-${ELECTRON_VERSION}-${ELECTRON_ARCH}.zip" \
|
||
"${CACHE_DIR}/electron/electron-${ELECTRON_VERSION}-${ELECTRON_ARCH}.zip" \
|
||
"Electron ${ELECTRON_VERSION}" \
|
||
"" \
|
||
"104857600" # 最小 100MB
|
||
}
|
||
|
||
download_spec_kit() {
|
||
echo "[8/8] spec-kit"
|
||
clone_repo \
|
||
"https://github.com/github/spec-kit.git" \
|
||
"${CACHE_DIR}/spec-kit" \
|
||
"" \
|
||
"spec-kit"
|
||
}
|
||
|
||
download_jj() {
|
||
echo "jj (Jujutsu) 版本控制工具"
|
||
local jj_file="jj-${JJ_VERSION}-${JJ_ARCH}.tar.gz"
|
||
download_file \
|
||
"https://github.com/jj-vcs/jj/releases/download/${JJ_VERSION}/${jj_file}" \
|
||
"${CACHE_DIR}/jj/${jj_file}" \
|
||
"jj ${JJ_VERSION}" \
|
||
"" \
|
||
"1048576"
|
||
}
|
||
|
||
download_claude_plugins() {
|
||
echo "Claude Code 插件市场仓库"
|
||
|
||
# 官方插件市场(anthropics/claude-plugins-official)
|
||
clone_repo \
|
||
"https://gitea.szis.com.cn/github/anthropics-claude-plugins-official.git" \
|
||
"${CACHE_DIR}/claude-plugins/claude-plugins-official" \
|
||
"" \
|
||
"claude-plugins-official"
|
||
|
||
# UI/UX Pro Max 插件市场(nextlevelbuilder/ui-ux-pro-max-skill)
|
||
clone_repo \
|
||
"https://gitea.szis.com.cn/github/nextlevelbuilder-ui-ux-pro-max-skill.git" \
|
||
"${CACHE_DIR}/claude-plugins/ui-ux-pro-max-skill" \
|
||
"" \
|
||
"ui-ux-pro-max-skill"
|
||
|
||
# Svelte AI Tools 插件市场(sveltejs/ai-tools)
|
||
clone_repo \
|
||
"https://gitea.szis.com.cn/github/sveltejs-ai-tools.git" \
|
||
"${CACHE_DIR}/claude-plugins/svelte" \
|
||
"" \
|
||
"sveltejs-ai-tools"
|
||
|
||
# Superpowers 插件市场(obra/superpowers-marketplace)
|
||
clone_repo \
|
||
"https://gitea.szis.com.cn/github/obra-superpowers.git" \
|
||
"${CACHE_DIR}/claude-plugins/superpowers-marketplace" \
|
||
"" \
|
||
"superpowers-marketplace"
|
||
}
|
||
|
||
download_vscode_extension() {
|
||
local extension_id=$1
|
||
local requested_version=""
|
||
local publisher
|
||
local extension_name
|
||
local output_file
|
||
local temp_dir
|
||
local max_retries=3
|
||
local retry_count=0
|
||
|
||
# 解析 @version 语法(如 anthropic.claude-code@2.1.112)
|
||
if [[ "${extension_id}" == *@* ]]; then
|
||
requested_version="${extension_id##*@}"
|
||
extension_id="${extension_id%@*}"
|
||
fi
|
||
|
||
publisher=${extension_id%%.*}
|
||
extension_name=${extension_id#*.}
|
||
output_file="${CACHE_DIR}/vscode/${extension_id}.vsix"
|
||
temp_dir=$(mktemp -d)
|
||
|
||
# 清理临时目录的 trap
|
||
trap 'rm -rf "$temp_dir" 2>/dev/null' RETURN
|
||
|
||
# 如果文件已存在,验证完整性和版本
|
||
if file_exists "$output_file"; then
|
||
|
||
# 验证 ZIP 格式、大小和解压测试
|
||
# 优先使用 unzip 测试,备用 file 命令
|
||
local is_valid=false
|
||
if command -v file >/dev/null 2>&1; then
|
||
file "$output_file" 2>/dev/null | grep -qE "(Zip archive|POSIX zip archive)" && is_valid=true
|
||
fi
|
||
|
||
# 无论 file 命令是否可用,都用 unzip 进行最终验证
|
||
[ "$(stat -c%s "$output_file" 2>/dev/null || echo 0)" -gt 1000 ] \
|
||
&& unzip -t -q "$output_file" -d "$temp_dir" >/dev/null 2>&1 && is_valid=true
|
||
|
||
if [ "$is_valid" = true ]; then
|
||
|
||
# 检查版本更新
|
||
local local_version
|
||
local remote_version
|
||
local_version=$(get_vscode_extension_version "$output_file")
|
||
if [ -n "${requested_version}" ]; then
|
||
remote_version="${requested_version}"
|
||
else
|
||
remote_version=$(get_vscode_latest_version "$extension_id")
|
||
fi
|
||
|
||
if [ -n "${local_version}" ] && [ -n "${remote_version}" ]; then
|
||
if [ "${local_version}" != "${remote_version}" ]; then
|
||
print_status_update "${extension_id}" "${local_version}" "${remote_version}"
|
||
rm -f "$output_file"
|
||
else
|
||
print_status_skip "${extension_id}" "${local_version}"
|
||
save_checksum "$output_file"
|
||
return 0
|
||
fi
|
||
elif [ -n "${remote_version}" ]; then
|
||
# 能获取远程版本但无法获取本地版本,文件可能有问题,重新下载
|
||
print_status_warn "${extension_id} 无法解析本地版本,重新下载"
|
||
rm -f "$output_file"
|
||
elif [ -n "${local_version}" ]; then
|
||
# 能获取本地版本但无法获取远程版本(网络问题?)
|
||
print_status_warn "${extension_id} 无法获取远程版本 (v${local_version}),跳过更新"
|
||
save_checksum "$output_file"
|
||
return 0
|
||
else
|
||
# 完全无法获取版本信息,但文件完整性检查通过,保留
|
||
print_status_warn "${extension_id} 无法获取版本信息,但文件完整性检查通过"
|
||
save_checksum "$output_file"
|
||
return 0
|
||
fi
|
||
else
|
||
print_status_fail "${extension_id}" "文件损坏"
|
||
rm -f "$output_file"
|
||
# 确保文件被删除
|
||
[ -f "$output_file" ] && rm -rf "$output_file" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
local download_url
|
||
local url_version="latest"
|
||
if [ -n "${requested_version}" ]; then
|
||
url_version="${requested_version}"
|
||
fi
|
||
# 注意:某些扩展(如 ms-python.python)的 API 不支持 targetPlatform 参数
|
||
# 这些扩展会自动根据请求头检测平台,但可能不准确
|
||
download_url="https://${publisher}.gallery.vsassets.io/_apis/public/gallery/publisher/${publisher}/extension/${extension_name}/${url_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage"
|
||
|
||
# 下载(带重试)
|
||
while [ $retry_count -lt $max_retries ]; do
|
||
print_status_get "${extension_id}"
|
||
printf " %s 下载地址: %s\n" "$(status_info)" "${download_url}"
|
||
|
||
local wget_exit_code=0
|
||
wget --tries=2 --waitretry=2 --show-progress \
|
||
--connect-timeout=10 --timeout=300 \
|
||
-U "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \
|
||
-O "$output_file" \
|
||
"$download_url" 2>&1 || wget_exit_code=$?
|
||
|
||
if [ ${wget_exit_code} -eq 0 ]; then
|
||
# 验证文件格式、大小和解压测试
|
||
# 优先使用 unzip 测试,备用 file 命令
|
||
local is_valid=false
|
||
if command -v file >/dev/null 2>&1; then
|
||
file "$output_file" 2>/dev/null | grep -qE "(Zip archive|POSIX zip archive)" && is_valid=true
|
||
fi
|
||
|
||
# 无论 file 命令是否可用,都用 unzip 进行最终验证
|
||
[ "$(stat -c%s "$output_file" 2>/dev/null || echo 0)" -gt 1000 ] \
|
||
&& unzip -t -q "$output_file" -d "$temp_dir" >/dev/null 2>&1 && is_valid=true
|
||
|
||
if [ "$is_valid" = true ]; then
|
||
print_status_ok "${extension_id}"
|
||
save_checksum "$output_file"
|
||
return 0
|
||
else
|
||
((retry_count++))
|
||
if [ $retry_count -lt $max_retries ]; then
|
||
print_status_retry "${extension_id}" "${retry_count}"
|
||
rm -f "$output_file"
|
||
# 确保文件被删除
|
||
[ -f "$output_file" ] && rm -rf "$output_file" 2>/dev/null || true
|
||
sleep 1
|
||
fi
|
||
fi
|
||
else
|
||
((retry_count++))
|
||
if [ $retry_count -lt $max_retries ]; then
|
||
sleep 1
|
||
fi
|
||
fi
|
||
done
|
||
|
||
print_status_fail "${extension_id}" "已重试 ${max_retries} 次"
|
||
rm -f "$output_file"
|
||
# 强制删除损坏的文件(多重保障)
|
||
if [ -f "$output_file" ]; then
|
||
rm -rf "$output_file" 2>/dev/null
|
||
sync && sleep 0.1
|
||
[ -f "$output_file" ] && rm -f "$output_file" 2>/dev/null || true
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# 清理损坏的 VSCode 扩展文件
|
||
cleanup_corrupted_vscode_extensions() {
|
||
local cache_dir="${CACHE_DIR}/vscode"
|
||
[ ! -d "${cache_dir}" ] && return 0
|
||
|
||
echo "清理损坏的 VSCode 扩展文件..."
|
||
cd "${cache_dir}" || return 1
|
||
|
||
local corrupted_count=0
|
||
local temp_dir
|
||
temp_dir=$(mktemp -d)
|
||
|
||
for vsix_file in *.vsix; do
|
||
[ ! -f "${vsix_file}" ] && continue
|
||
|
||
# 检查文件格式和完整性
|
||
# 优先使用 unzip 测试,备用 file 命令
|
||
local is_corrupted=false
|
||
local file_size=$(stat -c%s "${vsix_file}" 2>/dev/null || echo 0)
|
||
|
||
# 检查文件大小
|
||
[ "${file_size}" -lt 1000 ] && is_corrupted=true
|
||
|
||
# 检查文件格式(如果 file 命令可用)
|
||
if command -v file >/dev/null 2>&1 && [ "$is_corrupted" = false ]; then
|
||
file "${vsix_file}" 2>/dev/null | grep -qE "(Zip archive|POSIX zip archive)" || is_corrupted=true
|
||
fi
|
||
|
||
# 使用 unzip 进行最终验证
|
||
if [ "$is_corrupted" = false ]; then
|
||
unzip -t -q "${vsix_file}" -d "$temp_dir" >/dev/null 2>&1 || is_corrupted=true
|
||
fi
|
||
|
||
if [ "$is_corrupted" = true ]; then
|
||
echo " 删除损坏文件: $(text_gray "${vsix_file}")"
|
||
rm -f "${vsix_file}"
|
||
((corrupted_count++))
|
||
fi
|
||
done
|
||
|
||
rm -rf "$temp_dir"
|
||
|
||
if [ "${corrupted_count}" -eq 0 ]; then
|
||
echo " $(status_ok) 所有文件完整"
|
||
else
|
||
echo " $(status_warn) 已删除 $(text_yellow "${corrupted_count}") 个损坏文件"
|
||
fi
|
||
}
|
||
|
||
download_vscode_extensions() {
|
||
echo "VSCode 扩展"
|
||
cd "${CACHE_DIR}/vscode" || return 1
|
||
|
||
# 先清理损坏的扩展文件
|
||
cleanup_corrupted_vscode_extensions
|
||
|
||
# 已知有平台问题的扩展列表(VSCode 会自动安装正确的平台版本)
|
||
# 或 Marketplace API 不支持直接下载的扩展
|
||
local skip_extensions=(
|
||
"highagency.pencildev"
|
||
"ms-python.vscode-pylance"
|
||
"tamasfe.even-better-toml"
|
||
)
|
||
|
||
local overall_result=0
|
||
local pids=()
|
||
|
||
for extension in "${VSCODE_EXTENSIONS[@]}"; do
|
||
# 检查是否在跳过列表中
|
||
local should_skip=false
|
||
for skip_ext in "${skip_extensions[@]}"; do
|
||
if [ "${extension}" = "${skip_ext}" ]; then
|
||
printf " %s %s: $(text_gray '跳过预下载(将在容器启动时自动安装)')\n" "$(status_info)" "${extension}"
|
||
should_skip=true
|
||
break
|
||
fi
|
||
done
|
||
|
||
if [ "${should_skip}" = false ]; then
|
||
download_vscode_extension "$extension" &
|
||
pids+=($!)
|
||
fi
|
||
done
|
||
|
||
# 等待所有后台进程并收集结果
|
||
for pid in "${pids[@]}"; do
|
||
if ! wait "${pid}"; then
|
||
overall_result=1
|
||
fi
|
||
done
|
||
|
||
return ${overall_result}
|
||
}
|
||
|
||
# 从 VSCode 扩展中提取 Claude CLI 二进制文件
|
||
extract_claude_binary() {
|
||
echo "Claude CLI 二进制文件"
|
||
mkdir -p "${CACHE_DIR}/claude-bin"
|
||
|
||
local claude_output="${CACHE_DIR}/claude-bin/claude"
|
||
|
||
# 如果已缓存且文件完整,跳过
|
||
if [ -f "${claude_output}" ] && [ -s "${claude_output}" ]; then
|
||
local cached_version
|
||
cached_version=$("${claude_output}" --version 2>/dev/null | head -1 || echo "unknown")
|
||
print_status_skip "Claude CLI" "${cached_version}"
|
||
save_checksum "${claude_output}"
|
||
return 0
|
||
fi
|
||
|
||
local found_binary=""
|
||
|
||
# 搜索已安装的 VSCode 扩展目录
|
||
local vscode_ext_dirs=(
|
||
"${HOME}/.vscode/extensions"
|
||
"${HOME}/.vscode-server/extensions"
|
||
"${HOME}/.cursor/extensions"
|
||
)
|
||
|
||
for ext_dir in "${vscode_ext_dirs[@]}"; do
|
||
[ ! -d "${ext_dir}" ] && continue
|
||
|
||
# 查找最新版本的 Claude Code 扩展目录
|
||
local claude_ext
|
||
claude_ext=$(ls -d "${ext_dir}/anthropic.claude-code-"* 2>/dev/null | sort -V | tail -1)
|
||
[ -z "${claude_ext}" ] && continue
|
||
|
||
printf " %s 找到扩展: %s\n" "$(status_info)" "$(basename "${claude_ext}")"
|
||
|
||
# 在扩展目录中查找 claude 可执行文件
|
||
found_binary=$(find "${claude_ext}" -maxdepth 4 -name "claude" -type f 2>/dev/null | head -1)
|
||
[ -n "${found_binary}" ] && break
|
||
done
|
||
|
||
# 检查缓存的 VSIX 文件
|
||
if [ -z "${found_binary}" ]; then
|
||
local vsix_file
|
||
vsix_file=$(ls "${CACHE_DIR}/vscode/"anthropic.claude-code*.vsix 2>/dev/null | sort -V | tail -1)
|
||
|
||
if [ -n "${vsix_file}" ] && [ -f "${vsix_file}" ]; then
|
||
printf " %s 从 VSIX 提取: %s\n" "$(status_info)" "$(basename "${vsix_file}")"
|
||
|
||
local temp_dir
|
||
temp_dir=$(mktemp -d)
|
||
|
||
# 在 VSIX 中查找 claude 二进制
|
||
local claude_in_vsix
|
||
claude_in_vsix=$(unzip -l "${vsix_file}" 2>/dev/null \
|
||
| awk '{print $NF}' \
|
||
| grep -E '/claude$' \
|
||
| head -1)
|
||
|
||
if [ -n "${claude_in_vsix}" ]; then
|
||
unzip -j -o "${vsix_file}" "${claude_in_vsix}" -d "${temp_dir}" >/dev/null 2>&1
|
||
if [ -f "${temp_dir}/claude" ] && [ -s "${temp_dir}/claude" ]; then
|
||
found_binary="${temp_dir}/claude"
|
||
fi
|
||
fi
|
||
|
||
rm -rf "${temp_dir}"
|
||
fi
|
||
fi
|
||
|
||
if [ -n "${found_binary}" ]; then
|
||
cp -f "${found_binary}" "${claude_output}"
|
||
chmod +x "${claude_output}"
|
||
local version
|
||
version=$("${claude_output}" --version 2>/dev/null | head -1 || echo "unknown")
|
||
printf " %s 已复制 Claude CLI (%s)\n" "$(status_ok)" "${version}"
|
||
save_checksum "${claude_output}"
|
||
else
|
||
printf " %s 未找到 Claude Code 扩展,跳过 CLI 提取\n" "$(status_info)"
|
||
fi
|
||
}
|
||
|
||
# ============================================================================
|
||
# 显示统计信息
|
||
# ============================================================================
|
||
|
||
print_stat() {
|
||
local label=$1
|
||
local value=$2
|
||
local max_width=$3
|
||
local padding
|
||
|
||
padding=$((max_width - $(calc_width "${label}:")))
|
||
printf " %s%*s%s\n" "${label}:" "$padding" "" "${value}"
|
||
}
|
||
|
||
get_size() {
|
||
local path=$1
|
||
local is_dir=$2
|
||
local expanded_path
|
||
|
||
expanded_path=$(echo ${path} 2>/dev/null | head -n1)
|
||
|
||
if [ -e "${path}" ]; then
|
||
if [ "${is_dir}" = "dir" ]; then
|
||
du -sh "${path}" 2>/dev/null | cut -f1
|
||
else
|
||
du -h "${path}" 2>/dev/null | cut -f1
|
||
fi
|
||
elif [ -n "${expanded_path}" ] && [ -e "${expanded_path}" ]; then
|
||
if [ "${is_dir}" = "dir" ]; then
|
||
du -sh "${expanded_path}" 2>/dev/null | cut -f1
|
||
else
|
||
du -h "${expanded_path}" 2>/dev/null | cut -f1
|
||
fi
|
||
else
|
||
echo "未下载"
|
||
fi
|
||
}
|
||
|
||
show_stats() {
|
||
echo ""
|
||
echo "资源下载完成!"
|
||
echo ""
|
||
echo "下载统计:"
|
||
|
||
# 定义统计项
|
||
local items=(
|
||
"NVM 仓库|${CACHE_DIR}/nvm|dir"
|
||
"UV 二进制|${CACHE_DIR}/uv|dir"
|
||
"Python 预编译包|${CACHE_DIR}/uv/cpython-*.tar.gz|file"
|
||
"Node.js|${CACHE_DIR}/node|dir"
|
||
"npm 包|${CACHE_DIR}/npm|dir"
|
||
"Chrome|${CACHE_DIR}/chrome|dir"
|
||
"Electron|${CACHE_DIR}/electron|dir"
|
||
"spec-kit|${CACHE_DIR}/spec-kit|dir"
|
||
"VSCode 扩展|${CACHE_DIR}/vscode|dir"
|
||
"Claude CLI|${CACHE_DIR}/claude-bin|dir"
|
||
)
|
||
|
||
# 计算最大标签宽度
|
||
local max_width=0
|
||
local width
|
||
for label in "NVM 仓库" "UV 二进制" "Python 预编译包" \
|
||
"Node.js" "npm 包" "Chrome" "Electron" \
|
||
"spec-kit" "VSCode 扩展" "Claude CLI" "VSCode 扩展数量" "总大小"
|
||
do
|
||
width=$(calc_width "${label}:")
|
||
[ "$width" -gt "$max_width" ] && max_width=$width
|
||
done
|
||
|
||
# 输出资源统计
|
||
for item in "${items[@]}"; do
|
||
local label="${item%%|*}"
|
||
local path="${item#*|}"
|
||
local type="${path##*|}"
|
||
path="${path%|*}"
|
||
print_stat "${label}" "$(get_size "${path}" "${type}")" "$max_width"
|
||
done
|
||
|
||
# 输出扩展数量和总大小
|
||
print_stat "VSCode 扩展数量" "$(ls -1 ${CACHE_DIR}/vscode/*.vsix 2>/dev/null | wc -l)" "$max_width"
|
||
print_stat "总大小" "$(du -sh "${CACHE_DIR}" 2>/dev/null | cut -f1)" "$max_width"
|
||
echo ""
|
||
}
|
||
|
||
show_help() {
|
||
cat << EOF
|
||
用法: $0 [选项]
|
||
|
||
选项:
|
||
--cleanup-only 仅清理旧版本的包文件,不下载新资源
|
||
--force-update 强制更新所有包,即使版本相同也重新下载
|
||
--skip-extensions 跳过 VSCode 扩展下载
|
||
--skip-electron 跳过 Electron 下载
|
||
--help 显示此帮助信息
|
||
|
||
示例:
|
||
$0 # 下载所有资源
|
||
$0 --cleanup-only # 仅清理旧版本
|
||
$0 --force-update # 强制更新所有包
|
||
$0 --skip-extensions # 跳过 VSCode 扩展
|
||
|
||
注意:
|
||
- 定期运行此脚本以更新缓存
|
||
- 脚本会自动清理同名旧版本的包文件
|
||
- 如果遇到版本问题,使用 --force-update 强制重新下载
|
||
- 缓存文件可以提交到 Git 仓库或在团队间共享
|
||
- Node.js、UV 和 Python 二进制文件预下载,构建时直接复制
|
||
- Python 使用 uv 的 python-build-standalone 预编译版本
|
||
- Google Chrome 供 chrome-devtools-mcp 和 @playwright/mcp 共用
|
||
- Electron 使用 npmmirror.com 国内镜像源下载
|
||
- VSCode 扩展预下载,构建时直接安装
|
||
|
||
EOF
|
||
}
|
||
|
||
main() {
|
||
local cleanup_only=false
|
||
local force_update=false
|
||
local skip_extensions=false
|
||
local skip_electron=false
|
||
|
||
# 解析命令行参数
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--cleanup-only)
|
||
cleanup_only=true
|
||
shift
|
||
;;
|
||
--force-update)
|
||
force_update=true
|
||
shift
|
||
;;
|
||
--skip-extensions)
|
||
skip_extensions=true
|
||
shift
|
||
;;
|
||
--skip-electron)
|
||
skip_electron=true
|
||
shift
|
||
;;
|
||
--help|-h)
|
||
show_help
|
||
exit 0
|
||
;;
|
||
*)
|
||
echo "未知选项: $1"
|
||
show_help
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
create_cache_dirs
|
||
create_volumes_dirs
|
||
|
||
# 如果只是清理,执行清理后退出
|
||
if [ "${cleanup_only}" = true ]; then
|
||
print_header "清理旧版本包文件"
|
||
|
||
echo ""
|
||
echo "清理 npm 包..."
|
||
cd "${CACHE_DIR}/npm" || exit 1
|
||
cleanup_old_npm_packages
|
||
|
||
echo ""
|
||
echo "清理 VSCode 扩展..."
|
||
cd "${CACHE_DIR}/vscode" || exit 1
|
||
cleanup_corrupted_vscode_extensions
|
||
|
||
echo ""
|
||
echo "$(text_green '✅ 清理完成!')"
|
||
exit 0
|
||
fi
|
||
|
||
print_header "开始下载资源..."
|
||
|
||
# 批次 1: 无依赖的大型二进制和仓库(全并行)
|
||
download_nvm &
|
||
download_uv_binary &
|
||
download_python &
|
||
download_nodejs &
|
||
download_chrome &
|
||
download_spec_kit &
|
||
download_jj &
|
||
download_claude_plugins &
|
||
wait
|
||
|
||
# Electron(可选)
|
||
if [ "${skip_electron}" = false ] && [ -n "${ELECTRON_VERSION}" ]; then
|
||
download_electron
|
||
else
|
||
echo "Electron 二进制文件 - $(text_yellow '跳过')"
|
||
fi
|
||
|
||
# 批次 2: npm 包(依赖 npm 环境)
|
||
if [ "${force_update}" = true ]; then
|
||
echo ""
|
||
echo "$(text_yellow '强制更新模式:删除所有现有 npm 包...')"
|
||
rm -f ${CACHE_DIR}/npm/*.tgz
|
||
fi
|
||
download_npm_packages
|
||
|
||
# 批次 3: VSCode 扩展(已有内部并行)
|
||
if [ "${skip_extensions}" = false ]; then
|
||
download_vscode_extensions
|
||
fi
|
||
|
||
# 从 VSCode 扩展中提取 Claude CLI 二进制文件
|
||
extract_claude_binary
|
||
|
||
echo ""
|
||
print_section "验证下载完整性..."
|
||
|
||
# 验证所有文件的校验和
|
||
verify_all_checksums
|
||
|
||
show_stats
|
||
|
||
echo ""
|
||
echo "$(text_green '✅ 所有资源下载完成!')"
|
||
}
|
||
|
||
main "$@"
|