#!/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 "$@"