#!/usr/bin/env bash # # macOS 版前端构建:与 scripts/build-frontend.ps1 保持相同的输入指纹、缓存与产物布局。 # 生成物写入 .caiwu-build/public,供 deploy-from-mac.sh 作为镜像构建上下文使用。 # # 用法:bash scripts/build-frontend.sh [--publish-local] [--node-version 24.22.1] # set -euo pipefail NODE_VERSION="${CAIWU_NODE_VERSION:-24.22.1}" MAX_OLD_SPACE_SIZE_MB="${CAIWU_MAX_OLD_SPACE_MB:-4096}" TERSER_WORKERS="${CAIWU_TERSER_WORKERS:-2}" PUBLISH_LOCAL=0 while [ "$#" -gt 0 ]; do case "$1" in --publish-local) PUBLISH_LOCAL=1 ;; --node-version) NODE_VERSION="$2"; shift ;; --max-old-space-size-mb) MAX_OLD_SPACE_SIZE_MB="$2"; shift ;; --terser-workers) TERSER_WORKERS="$2"; shift ;; -h|--help) sed -n '2,8p' "$0"; exit 0 ;; *) echo "未知参数:$1" >&2; exit 2 ;; esac shift done script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "$script_dir/.." && pwd)" tool_root="$HOME/.cache/CaiWuXiTong/tools" case "$(uname -m)" in arm64) node_platform='darwin-arm64' ;; x86_64) node_platform='darwin-x64' ;; *) echo "不支持的 macOS 架构:$(uname -m)" >&2; exit 1 ;; esac node_folder="node-v$NODE_VERSION-$node_platform" node_root="$tool_root/$node_folder" node_exe="$node_root/bin/node" npm_cmd="$node_root/bin/npm" node_archive="$tool_root/$node_folder.tar.gz" node_checksums="$tool_root/SHASUMS256.txt" tracked_css="$repo_root/public/css/app.css" public_js="$repo_root/public/js" public_manifest="$repo_root/public/mix-manifest.json" cache_root="$repo_root/.caiwu-build" output_root="$cache_root/public" vendor_root="$output_root/vendor" js_fingerprint_file="$cache_root/frontend-js.sha" css_fingerprint_file="$cache_root/frontend-css.sha" dependency_fingerprint_file="$cache_root/node-modules.sha" js_manifest_cache="$cache_root/manifest-js.json" css_manifest_cache="$cache_root/manifest-css.json" output_fingerprint_file="$cache_root/frontend-output.sha" sha256_file() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' else sha256sum "$1" | awk '{print $1}' fi } sha256_stdin() { if command -v shasum >/dev/null 2>&1; then shasum -a 256 | awk '{print $1}' else sha256sum | awk '{print $1}' fi } path_fingerprint() { local list_file content path list_file="$(mktemp)" for path in "$@"; do if [ -f "$repo_root/$path" ]; then printf '%s\n' "$repo_root/$path" >> "$list_file" elif [ -d "$repo_root/$path" ]; then find "$repo_root/$path" -type f >> "$list_file" else rm -f "$list_file" echo "前端指纹路径不存在:$path" >&2 return 1 fi done LC_ALL=C sort -o "$list_file" "$list_file" content="$(while IFS= read -r file; do printf '%s|%s\n' "${file#"$repo_root"/}" "$(sha256_file "$file")" done < "$list_file")" rm -f "$list_file" printf '%s' "$content" | sha256_stdin } cache_hit() { local fingerprint_file="$1" expected="$2" shift 2 [ -f "$fingerprint_file" ] || return 1 for output in "$@"; do [ -e "$output" ] || return 1 done [ "$(cat "$fingerprint_file")" = "$expected" ] } write_file() { printf '%s' "$2" > "$1" } download_file() { local url="$1" destination="$2" attempt=1 while [ "$attempt" -le 4 ]; do if curl -fL --retry 3 --retry-delay 2 --retry-all-errors \ --speed-limit 20000 --speed-time 20 \ -C - -o "$destination" "$url"; then return 0 fi attempt=$((attempt + 1)) sleep 2 done return 1 } publish_frontend_cache() { for required in "$output_root/js" "$output_root/css/app.css" "$output_root/mix-manifest.json"; do [ -e "$required" ] || { echo "前端缓存产物缺失:$required" >&2; exit 1; } done rm -rf "$public_js" cp -R "$output_root/js" "$public_js" cp "$output_root/css/app.css" "$tracked_css" cp "$output_root/mix-manifest.json" "$public_manifest" } package_lock_fingerprint="$(path_fingerprint package-lock.json)" js_fingerprint="$NODE_VERSION|$TERSER_WORKERS|$(path_fingerprint resources/assets/js package.json package-lock.json scripts/build-frontend.ps1 scripts/build-frontend.sh webpack.mix.js)" css_fingerprint="$NODE_VERSION|$(path_fingerprint resources/assets/sass resources/views resources/lang package.json package-lock.json webpack.mix.js tailwind.config.js presets.js safelist.txt composer.lock)" dependency_fingerprint="$NODE_VERSION|$package_lock_fingerprint" output_cache_valid=0 if [ -f "$output_fingerprint_file" ] && [ -d "$output_root" ] \ && [ "$(cat "$output_fingerprint_file")" = "$(path_fingerprint .caiwu-build/public)" ]; then output_cache_valid=1 fi js_cache_valid=0 if cache_hit "$js_fingerprint_file" "$js_fingerprint" \ "$output_root/js/auth/common.min.js" \ "$output_root/js/business/pages.min.js" \ "$output_root/js/business/marketing-account-bills.min.js" \ "$output_root/js/manifest.js" \ "$output_root/js/vendor.js" \ "$vendor_root/apexcharts/apexcharts.min.js" \ "$vendor_root/js-cookie/js.cookie.js" \ "$vendor_root/quicksand/css/quicksand.css" \ "$vendor_root/quicksand/files/quicksand-latin-400-normal.woff2" \ "$js_manifest_cache"; then js_cache_valid=1 fi css_cache_valid=0 if cache_hit "$css_fingerprint_file" "$css_fingerprint" "$output_root/css/app.css" "$css_manifest_cache"; then css_cache_valid=1 fi if [ "$output_cache_valid" -eq 0 ]; then js_cache_valid=0 css_cache_valid=0 echo '前端产物完整性缺失或已变化,重新构建资源。' fi if [ "$js_cache_valid" -eq 1 ] && [ "$css_cache_valid" -eq 1 ]; then echo '前端输入未变化,复用 JavaScript、CSS 与依赖缓存。' if [ "$PUBLISH_LOCAL" -eq 1 ]; then publish_frontend_cache echo '已把缓存中的前端资源发布到本地 public 目录。' fi exit 0 fi if [ ! -x "$node_exe" ]; then mkdir -p "$tool_root" download_root="https://nodejs.org/dist/v$NODE_VERSION" if [ -n "${CAIWU_NODE_MIRROR:-}" ]; then download_sources="$CAIWU_NODE_MIRROR" else download_sources="$download_root https://registry.npmmirror.com/-/binary/node/v$NODE_VERSION" fi echo "下载项目专用 Node ${NODE_VERSION}(${node_platform})。" node_ready=0 for download_source in $download_sources; do if download_file "$download_source/$node_folder.tar.gz" "$node_archive" \ && download_file "$download_source/SHASUMS256.txt" "$node_checksums"; then node_ready=1 break fi echo "下载源 ${download_source} 中断,换下一个源重试。" rm -f "$node_archive" "$node_checksums" done if [ "$node_ready" -eq 0 ]; then echo '下载项目专用 Node 失败,请设置 CAIWU_NODE_MIRROR 指向可用的 Node 镜像后重试。' >&2 exit 1 fi expected_hash="$(awk -v name="$node_folder.tar.gz" '$2 == name { print $1; exit }' "$node_checksums")" if [ -z "$expected_hash" ]; then rm -f "$node_archive" "$node_checksums" echo 'Node 压缩包缺少官方 SHA-256 校验值。' >&2 exit 1 fi actual_hash="$(sha256_file "$node_archive")" if [ "$actual_hash" != "$expected_hash" ]; then rm -f "$node_archive" "$node_checksums" echo 'Node 压缩包 SHA-256 校验失败。' >&2 exit 1 fi tar -xzf "$node_archive" -C "$tool_root" rm -f "$node_archive" "$node_checksums" fi if [ ! -x "$node_exe" ] || [ ! -x "$npm_cmd" ]; then echo "项目专用 Node $NODE_VERSION 安装不完整。" >&2 exit 1 fi original_path="$PATH" tracked_css_backup="" if [ -f "$tracked_css" ]; then tracked_css_backup="$(mktemp)" cp "$tracked_css" "$tracked_css_backup" fi restore_tracked_css() { if [ "$PUBLISH_LOCAL" -eq 0 ] && [ -n "$tracked_css_backup" ] && [ -f "$tracked_css_backup" ]; then cp "$tracked_css_backup" "$tracked_css" fi if [ -n "$tracked_css_backup" ]; then rm -f "$tracked_css_backup" fi } trap restore_tracked_css EXIT cd "$repo_root" export PATH="$node_root/bin:$original_path" export NODE_OPTIONS="--max-old-space-size=$MAX_OLD_SPACE_SIZE_MB" export CAIWU_TERSER_WORKERS="$TERSER_WORKERS" "$node_exe" --version >/dev/null dependency_cache_valid=0 if [ -f "$repo_root/node_modules/.package-lock.json" ] && [ -f "$dependency_fingerprint_file" ] \ && [ "$(cat "$dependency_fingerprint_file")" = "$dependency_fingerprint" ]; then dependency_cache_valid=1 fi if [ "$dependency_cache_valid" -eq 1 ]; then echo 'package-lock.json 未变化,复用 node_modules。' else "$npm_cmd" ci --prefer-offline --no-audit --no-fund --legacy-peer-deps mkdir -p "$cache_root" write_file "$dependency_fingerprint_file" "$dependency_fingerprint" fi if [ "$js_cache_valid" -eq 0 ]; then echo "构建 JavaScript(Node 堆 ${MAX_OLD_SPACE_SIZE_MB}MB,压缩线程 ${TERSER_WORKERS})。" rm -rf "$public_js" CAIWU_BUILD_TARGET=js "$npm_cmd" run production for output in \ public/js/auth/common.min.js \ public/js/business/marketing-account-bills.min.js \ public/js/manifest.js \ public/js/vendor.js \ public/mix-manifest.json; do [ -f "$repo_root/$output" ] || { echo "JavaScript 构建产物缺失:$output" >&2; exit 1; } done rm -rf "$output_root/js" "$vendor_root" mkdir -p "$output_root" "$vendor_root/apexcharts" "$vendor_root/js-cookie" "$vendor_root/quicksand/css" "$vendor_root/quicksand/files" cp -R "$public_js" "$output_root/js" cp "$repo_root/node_modules/apexcharts/dist/apexcharts.min.js" "$vendor_root/apexcharts/apexcharts.min.js" cp "$repo_root/node_modules/js-cookie/dist/js.cookie.min.js" "$vendor_root/js-cookie/js.cookie.js" cp -R "$repo_root/node_modules/@fontsource/quicksand/files/." "$vendor_root/quicksand/files/" : > "$vendor_root/quicksand/css/quicksand.css" for weight in 400 500 600 700; do sed 's#url(\./files/#url(../files/#g' "$repo_root/node_modules/@fontsource/quicksand/$weight.css" >> "$vendor_root/quicksand/css/quicksand.css" printf '\n' >> "$vendor_root/quicksand/css/quicksand.css" done cp "$public_manifest" "$js_manifest_cache" write_file "$js_fingerprint_file" "$js_fingerprint" fi if [ "$css_cache_valid" -eq 0 ]; then echo "构建 CSS(Node 堆 ${MAX_OLD_SPACE_SIZE_MB}MB)。" CAIWU_BUILD_TARGET=css "$npm_cmd" run production for output in public/css/app.css public/mix-manifest.json; do [ -f "$repo_root/$output" ] || { echo "CSS 构建产物缺失:$output" >&2; exit 1; } done mkdir -p "$output_root/css" cp "$tracked_css" "$output_root/css/app.css" cp "$public_manifest" "$css_manifest_cache" write_file "$css_fingerprint_file" "$css_fingerprint" fi "$node_exe" -e ' const fs = require("fs"); const [jsPath, cssPath, outputPath, publicPath] = process.argv.slice(1); const merged = {}; for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(jsPath, "utf8")))) { if (key.endsWith(".js")) merged[key] = value; } for (const [key, value] of Object.entries(JSON.parse(fs.readFileSync(cssPath, "utf8")))) { if (key.endsWith(".css")) merged[key] = value; } const json = JSON.stringify(merged, null, 4); fs.writeFileSync(outputPath, json); fs.writeFileSync(publicPath, json); ' "$js_manifest_cache" "$css_manifest_cache" "$output_root/mix-manifest.json" "$public_manifest" if [ "$js_fingerprint" != "$NODE_VERSION|$TERSER_WORKERS|$(path_fingerprint resources/assets/js package.json package-lock.json scripts/build-frontend.ps1 scripts/build-frontend.sh webpack.mix.js)" ] \ || [ "$css_fingerprint" != "$NODE_VERSION|$(path_fingerprint resources/assets/sass resources/views resources/lang package.json package-lock.json webpack.mix.js tailwind.config.js presets.js safelist.txt composer.lock)" ]; then echo '构建过程中前端源码发生变化,请在稳定的提交上重试。' >&2 exit 1 fi write_file "$output_fingerprint_file" "$(path_fingerprint .caiwu-build/public)" echo '前端构建完成,产物位于 .caiwu-build/public。'