CaiWuXiTong/scripts/build-frontend.sh
renshen c7a8baea76 macOS 发布脚本对齐 Windows 版并补文档
- deploy-from-mac.sh 增加 --skip-regctl,并在 regctl 被安全软件拦截或隧道失败时
  自动改用 docker 直传(docker save + scp + docker load/push),与 Windows 版一致
- 修正若干在 set -e 下会被误判为失败的短路条件语句
- AGENTS.md 与《开发与部署SOP》补充 macOS 发布命令、钥匙串保存登录信息,
  以及 --allow-migrations、--skip-regctl、--allow-non-main 的对应参数
2026-09-29 16:04:05 +08:00

340 lines
12 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.

#!/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 20.20.2]
#
set -euo pipefail
NODE_VERSION="${CAIWU_NODE_VERSION:-20.20.2}"
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
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。'