diff --git a/README.md b/README.md index e0f0669..99fd8ee 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,12 @@ bash /tmp/install-skills.sh both | 情况 | 处理方式 | |------|---------| | 新 skill(本地没有) | 直接安装 | -| 本地未改 + 上游有更新 | 直接覆盖 | -| 本地魔改过 + 上游有更新 | 写入上游新版,本地版备份为 `SKILL.md.local.bak` | +| 本地未改 + 上游有更新 | 自动更新,并把旧版本备份为 `SKILL.md.local.bak` | +| 本地改过 + 上游有更新 | 保留本地版本,把上游新版写入 `SKILL.md.upstream.new` | | 本地和上游一致 | 跳过 | +安装脚本会在 `.codex/skills/.install-state/` 或 `.claude/skills/.install-state/` 记录上次安装的上游文件指纹。只有本地文件仍等于上次安装版本时才自动更新;如果用户改过同名 skill,本地特性默认不会被覆盖。 + 项目根目录 guide 文件单独处理: | 情况 | 处理方式 | @@ -112,6 +114,8 @@ bash /tmp/install-skills.sh both 恢复本地版本:`mv SKILL.md.local.bak SKILL.md` 对比差异:`diff SKILL.md SKILL.md.local.bak` +合并本地特性与上游新版:`diff SKILL.md SKILL.md.upstream.new` +确认要强制覆盖本地版本时:`bash install.sh codex --force-overwrite` ## 使用示例 diff --git a/install.sh b/install.sh index bf0624c..cb4ec57 100644 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # ============================================================ # spec-coding-skills 安装/更新脚本 -# 用法: bash <(curl -sL /install.sh) [codex|claude|both] -# 或: bash install.sh [codex|claude|both|目标目录] +# 用法: bash <(curl -sL /install.sh) [codex|claude|both] [--force-overwrite] +# 或: bash install.sh [codex|claude|both|目标目录] [--force-overwrite] # ============================================================ set -euo pipefail @@ -23,7 +23,30 @@ REQUEST="${1:-$DEFAULT_MODE}" TOTAL_NEW=0 TOTAL_UPDATED=0 TOTAL_SKIPPED=0 +TOTAL_PRESERVED=0 INSTALLED_TARGETS="" +FORCE_OVERWRITE="${SPEC_SKILLS_FORCE_OVERWRITE:-0}" + +if [ "$#" -gt 0 ]; then + REQUEST="$DEFAULT_MODE" + REQUEST_SET=0 + for arg in "$@"; do + case "$arg" in + --force|--force-overwrite) + FORCE_OVERWRITE=1 + ;; + *) + if [ "$REQUEST_SET" -eq 0 ]; then + REQUEST="$arg" + REQUEST_SET=1 + else + log_warn "未知参数: $arg" + exit 1 + fi + ;; + esac + done +fi resolve_layout() { local input="$1" @@ -70,6 +93,53 @@ resolve_layout() { TMP_DIR=$(mktemp -d) trap "rm -rf $TMP_DIR" EXIT +file_hash() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + log_warn "缺少 shasum 或 sha256sum,无法进行本地差异保护" + exit 1 + fi +} + +state_file_for() { + local dst="$1" + local rel_path + + rel_path="${dst#$TARGET/}" + if [ "$rel_path" = "$dst" ]; then + rel_path="$(basename "$dst")" + fi + + printf '%s/.install-state/%s.sha256' "$TARGET" "$rel_path" +} + +write_install_state() { + local src="$1" + local dst="$2" + local state_file + + state_file="$(state_file_for "$dst")" + mkdir -p "$(dirname "$state_file")" + file_hash "$src" >"$state_file" +} + +is_unmodified_since_last_install() { + local dst="$1" + local state_file + local dst_hash + local expected_hash + + state_file="$(state_file_for "$dst")" + [ -f "$state_file" ] || return 1 + + dst_hash="$(file_hash "$dst")" + expected_hash="$(cat "$state_file")" + [ "$dst_hash" = "$expected_hash" ] +} + sync_file() { local src="$1" local dst="$2" @@ -79,14 +149,23 @@ sync_file() { if [ ! -f "$dst" ]; then mkdir -p "$(dirname "$dst")" cp "$src" "$dst" + write_install_state "$src" "$dst" log_info "$create_msg" TOTAL_NEW=$((TOTAL_NEW + 1)) elif ! diff -q "$src" "$dst" >/dev/null 2>&1; then - cp "$dst" "$dst.local.bak" - cp "$src" "$dst" - log_warn "$update_msg" - TOTAL_UPDATED=$((TOTAL_UPDATED + 1)) + if [ "$FORCE_OVERWRITE" = "1" ] || is_unmodified_since_last_install "$dst"; then + cp "$dst" "$dst.local.bak" + cp "$src" "$dst" + write_install_state "$src" "$dst" + log_warn "$update_msg" + TOTAL_UPDATED=$((TOTAL_UPDATED + 1)) + else + cp "$src" "${dst}.upstream.new" + log_warn "🛡️ 保留本地差异: ${dst};远端新版已写入 ${dst}.upstream.new" + TOTAL_PRESERVED=$((TOTAL_PRESERVED + 1)) + fi else + write_install_state "$src" "$dst" TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)) fi } @@ -206,6 +285,7 @@ echo " 📦 版本: $VERSION" echo "" echo " ✨ 新增: $TOTAL_NEW" echo " 🔄 更新: ${TOTAL_UPDATED}(本地版本已备份为 .local.bak)" +echo " 🛡️ 保留本地差异: $TOTAL_PRESERVED" echo " ⏭️ 无变化: $TOTAL_SKIPPED" echo "" @@ -214,3 +294,9 @@ if [ "$TOTAL_UPDATED" -gt 0 ]; then log_warn "如需恢复本地版本: mv SKILL.md.local.bak SKILL.md" log_warn "如需对比差异: diff SKILL.md SKILL.md.local.bak" fi + +if [ "$TOTAL_PRESERVED" -gt 0 ]; then + log_warn "有 ${TOTAL_PRESERVED} 个本地改动文件被保护,远端新版已写入 *.upstream.new" + log_warn "如需合并: diff SKILL.md SKILL.md.upstream.new" + log_warn "如需强制覆盖: 重新运行并追加 --force-overwrite,或设置 SPEC_SKILLS_FORCE_OVERWRITE=1" +fi diff --git a/tests/install.sh.test b/tests/install.sh.test index b5ade05..803ebe6 100644 --- a/tests/install.sh.test +++ b/tests/install.sh.test @@ -84,10 +84,10 @@ EOF run_install() { local target_dir="$1" - local mode="$2" + shift ( cd "$target_dir" - PATH="$GIT_WRAPPER_DIR:$PATH" FAKE_UPSTREAM_REPO="$UPSTREAM_REPO" bash "$INSTALL_SCRIPT" "$mode" + PATH="$GIT_WRAPPER_DIR:$PATH" FAKE_UPSTREAM_REPO="$UPSTREAM_REPO" bash "$INSTALL_SCRIPT" "$@" ) } @@ -127,6 +127,23 @@ main() { assert_file_contains "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md" "demo codex v2" [[ -f "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md.local.bak" ]] || fail "skill backup should exist" + printf 'custom demo codex\n' >"$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md" + ( + cd "$UPSTREAM_REPO" + printf '# demo codex v3\n' > .codex/skills/demo/SKILL.md + git add .codex/skills/demo/SKILL.md + git commit -qm "upstream codex v3" + ) + + output="$(run_install "$TMP_ROOT/project-codex" codex)" + assert_equals "$(cat "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md")" "custom demo codex" + assert_file_contains "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md.upstream.new" "demo codex v3" + assert_output_contains "$output" "保留本地差异" + + output="$(run_install "$TMP_ROOT/project-codex" codex --force-overwrite)" + assert_file_contains "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md" "demo codex v3" + assert_equals "$(cat "$TMP_ROOT/project-codex/.codex/skills/demo/SKILL.md.local.bak")" "custom demo codex" + mkdir -p "$TMP_ROOT/project-claude" output="$(run_install "$TMP_ROOT/project-claude" claude)" assert_file_contains "$TMP_ROOT/project-claude/.claude/skills/demo/SKILL.md" "demo claude v1" @@ -153,7 +170,7 @@ main() { output="$(run_install "$TMP_ROOT/project-both" both)" assert_equals "$(cat "$TMP_ROOT/project-both/AGENTS.md")" "team agents" assert_equals "$(cat "$TMP_ROOT/project-both/CLAUDE.md")" "team claude" - assert_file_contains "$TMP_ROOT/project-both/.codex/skills/demo/SKILL.md" "demo codex v2" + assert_file_contains "$TMP_ROOT/project-both/.codex/skills/demo/SKILL.md" "demo codex v3" assert_file_contains "$TMP_ROOT/project-both/.claude/skills/demo/SKILL.md" "demo claude v2" assert_output_contains "$output" "跳过项目引导: AGENTS.md" assert_output_contains "$output" "跳过项目引导: CLAUDE.md"