Files
team/.devcontainer/download-resources.sh
T
2026-04-19 21:47:08 +08:00

1135 lines
39 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "$@"