Files
superpowers-zh/scripts/verify-release.sh
AI不止语 b01a91c267 fix(installer): Qwen Code 缺 bootstrap(skill 是死重)+ 文档把产品搞混了
核查第五款。路径本身是对的 —— 这是查过的几款里第一个没装错地方的。但另有
两个问题。

## ① 我们把产品搞混了

docs 标题写「Qwen Code (通义灵码)」,链接指向 tongyi.aliyun.com;README 里
把它标成「IDE 插件」。

实际:Qwen Code 是 QwenLM/qwen-code 这个**命令行工具**(Gemini CLI 的 fork),
跑在终端里。通义灵码是阿里的 IDE 插件,**是另一个产品**,配置路径完全不同。
照着我们的文档去配通义灵码是配不通的。

这跟 #119 的 Qoder 表「没标产品面」是同一类错误:把两个不同产品面的东西
混成一份说明。docs 与简繁 README 一并更正(类型也从「IDE 插件」改回 CLI)。

## ② 只装 skills、不写 bootstrap —— skill 是死重

实测装完项目里只有 .qwen/skills/,一个引导文件都没写。而 Claude 写 CLAUDE.md、
Gemini 写 GEMINI.md,Qwen Code 的对应物 QWEN.md 我们完全没用。

按上游的原则:没有 bootstrap,skill 就是死重 —— 文件在磁盘上但很少被调用。

两套机制都查了官方文档:
- skills:qwenlm.github.io/.../features/skills/ 确认 .qwen/skills/ 与
  ~/.qwen/skills/ 自动发现,无需配置
- QWEN.md:分层记忆的默认上下文文件就是 QWEN.md,从 cwd 逐层向上到项目根、
  以及全局 ~/.qwen/ 都会被扫描并拼接(可用 contextFileName 改名)

已补 generateQwenBootstrap,复用 Gemini 那套结构:项目级写 ./QWEN.md,全局写
~/.qwen/QWEN.md;已有文件则用哨兵注释追加而不覆盖。

顺带补上全局卸载的缺口:qwen 在 GLOBAL_OK 里,--global 会写 ~/.qwen/QWEN.md,
原来 GLOBAL_BOOTSTRAP_CLEAN_SECTION 只有 .claude/CLAUDE.md,装卸一轮会在用户
主目录留残留。

## 验证

- 项目级:已有 QWEN.md 时追加,卸载后逐字节恢复用户内容
- 全局:写 ~/.qwen/QWEN.md,卸载后 HOME 下残留 0 个文件
- verify-release 新增 Qwen 段(bootstrap 写入 / 不覆盖用户内容 / 指向
  .qwen/skills/ / 卸载切除干净 / 不误删用户内容 / 全局零残留),101 -> 108 pass
- audit.sh 166 pass / 0 warn / 0 fail

## 范围

installer、docs、简繁 README、verify-release 均为 fork 自有,零上游偏离。
2026-08-12 19:17:26 +08:00

238 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
#
# verify-release.sh — 发版前深度验证。
#
# 与 audit.sh 的分工audit.sh 只看 installer 的退出码,装完到底有没有东西落盘、
# 卸载有没有留垃圾它都不查。本脚本补上这些断言,发 npm 之前跑一次。
#
# bash scripts/verify-release.sh
#
# 覆盖:
# A. 22 款工具:装 -> 断言 skill 数落盘 -> 二次装幂等 -> 卸载零残留 + 无嵌套
# B. 每个检测标记只触发预期工具(防新增工具时误触发既有工具)
# C. --global 白名单成功 / 其余明确拒绝且退出码 1
# D. --global 拒绝信息引用的 docs 文件真实存在(防死链)
# E. rules 型工具Cline / Kilo Code的 bootstrap 索引内容断言
# F. PATH 探测在 PATH 为空 / 畸形 / 未定义时的健壮性
set -uo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
INS="$REPO/bin/superpowers-zh.js"
NODE=$(command -v node) # 绝对路径F 段要清空 PATH用裸 node 会 127
EXPECT_SKILLS=$(ls -d "$REPO"/skills/*/ | wc -l | tr -d ' ')
PASS=0; FAIL=0
declare -a FAILURES=()
ok() { PASS=$((PASS+1)); }
bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"); printf ' ❌ %s\n' "$1"; }
# 工具别名 -> 期望的 skills 目录(相对项目根)
declare -a SPEC=(
"claude:.claude/skills" "cursor:.cursor/skills" "codex:.codex/skills"
"kiro:.kiro/skills" "deerflow:skills/custom" "trae:.trae/skills"
"antigravity:.agents/skills" "vscode:.github/superpowers" "openclaw:skills"
"windsurf:.windsurf/skills" "gemini:.gemini/skills" "aider:.aider/skills"
"opencode:.opencode/skills" "qwen:.qwen/skills" "hermes:.hermes/skills"
"claw:.claw/skills" "copilot:.claude/skills" "qoder:.qoder/skills"
"codebuddy:.codebuddy/skills" "codearts:.codeartsdoer/skills"
"cline:.cline/skills" "kilocode:.kilocode/skills"
"crush:.crush/skills"
)
echo "═══ 期望每款装入 $EXPECT_SKILLS 个 skill ═══"
echo ""
echo "─── A. 项目级:装 / 落盘断言 / 幂等 / 卸载无残留22 款)───"
for entry in "${SPEC[@]}"; do
tool="${entry%%:*}"; sdir="${entry#*:}"
T=$(mktemp -d); cd "$T"
if ! node "$INS" --tool "$tool" >/dev/null 2>&1; then
bad "$tool: 安装退出码非 0"; rm -rf "$T"; continue
fi
n=$(ls -d "$T/$sdir"/*/ 2>/dev/null | wc -l | tr -d ' ')
if [ "$n" != "$EXPECT_SKILLS" ]; then
bad "$tool: $sdir 里是 $n 个 skill期望 $EXPECT_SKILLS"; rm -rf "$T"; continue
fi
# 嵌套 bug 检查:不应出现 skills/skills 之类
if find "$T" -type d -path "*/skills/skills" 2>/dev/null | grep -q .; then
bad "$tool: 出现嵌套 skills/skills 目录"
fi
node "$INS" --tool "$tool" >/dev/null 2>&1
n2=$(ls -d "$T/$sdir"/*/ 2>/dev/null | wc -l | tr -d ' ')
if [ "$n2" != "$EXPECT_SKILLS" ]; then
bad "$tool: 二次安装后变成 $n2 个(幂等性破坏)"; rm -rf "$T"; continue
fi
if ! node "$INS" --uninstall >/dev/null 2>&1; then
bad "$tool: 卸载退出码非 0"; rm -rf "$T"; continue
fi
left=$(find "$T" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "$left" != "0" ]; then
bad "$tool: 卸载后残留 $left 个文件: $(find "$T" -type f | head -3 | tr '\n' ' ')"
else
ok
fi
cd /; rm -rf "$T"
done
echo ""
echo "─── B. 自动检测:每个标记只触发预期工具 ───"
declare -a DETECT=(
".claude:Claude Code" ".cursor:Cursor" ".codex:Codex CLI"
".kiro:Kiro" ".trae:Trae" ".agents:Antigravity"
".openclaw:OpenClaw" ".windsurf:Windsurf" ".aider:Aider"
# Aider 的真实标记:它不创建 .aider/ 目录,留下的是 .aider. 前缀的产物。
# 只测 ".aider" 等于拿代码测代码 —— 真实 Aider 项目一个都匹配不上。
".aider.conf.yml:Aider" ".aider.chat.history.md:Aider" ".aider.tags.cache.v3:Aider"
".opencode:OpenCode" ".qwen:Qwen Code" ".hermes:Hermes Agent"
".claw:Claw Code" ".qoder:Qoder" ".codebuddy:CodeBuddy"
".codeartsdoer:CodeArts" ".clinerules:Cline" ".kilocode:Kilo Code"
".kilo:Kilo Code" ".crush:Crush"
"deer_flow:DeerFlow" ".github/copilot-instructions.md:VS Code"
"GEMINI.md:Gemini CLI"
)
for entry in "${DETECT[@]}"; do
marker="${entry%%:*}"; want="${entry#*:}"
T=$(mktemp -d); cd "$T"
case "$marker" in
# .yml 也要按「文件」建 —— .aider.conf.yml 是文件不是目录。existsSync 两者都
# 匹配得上,但用目录冒充文件等于测了个假场景,下次改检测逻辑就发现不了问题。
*.md|*.json|*.yml) mkdir -p "$(dirname "$marker")" 2>/dev/null; : > "$marker" ;;
*) mkdir -p "$marker" ;;
esac
got=$(node "$INS" 2>&1 | grep -oE '✅ [A-Za-z][A-Za-z ]*(\[|:)' | sed 's/✅ //; s/ *[:[]$//' | sort -u | tr '\n' ',' | sed 's/,$//')
if [ "$got" != "$want" ]; then
bad "检测 ${marker}/ -> 得到「${got}」,期望「${want}"
else
ok
fi
cd /; rm -rf "$T"
done
echo ""
echo "─── C. --global7 款应成功,其余应明确拒绝且退出码 1 ───"
declare -a GLOBAL_OK=(claude codex openclaw windsurf opencode qwen qoder crush hermes)
declare -a GLOBAL_NO=(cursor kiro trae aider deerflow vscode claw gemini antigravity codebuddy codearts cline kilocode)
for tool in "${GLOBAL_OK[@]}"; do
H=$(mktemp -d)
if HOME="$H" node "$INS" --global --tool "$tool" >/dev/null 2>&1; then ok; else bad "--global $tool 应成功但失败"; fi
rm -rf "$H"
done
for tool in "${GLOBAL_NO[@]}"; do
H=$(mktemp -d)
out=$(HOME="$H" node "$INS" --global --tool "$tool" 2>&1); rc=$?
if [ $rc -eq 0 ]; then bad "--global $tool 应拒绝但成功了"
elif ! echo "$out" | grep -q "不支持通用全局安装"; then bad "--global $tool 拒绝信息缺失"
else ok; fi
rm -rf "$H"
done
echo ""
echo "─── D. --global 拒绝信息里引用的 docs 文件必须存在 ───"
for slug in gemini-cli antigravity trae aider hermes kiro cline kilocode; do
if [ -f "$REPO/docs/README.$slug.md" ]; then ok; else bad "docs/README.$slug.md 不存在(拒绝信息会指向死链)"; fi
done
echo ""
echo "─── E. 新工具 bootstrap 索引内容断言 ───"
T=$(mktemp -d); cd "$T"; node "$INS" --tool cline >/dev/null 2>&1
R="$T/.clinerules/superpowers-zh.md"
[ -f "$R" ] && ok || bad "Cline 索引文件未生成"
head -1 "$R" | grep -q '^---' && bad "Cline 索引不该有 YAML frontmatterCline 只支持 paths 字段)" || ok
rows=$(grep -cE '^\| [a-z][a-z0-9-]+ \|' "$R")
[ "$rows" = "$EXPECT_SKILLS" ] && ok || bad "Cline 索引表 $rows 行,期望 $EXPECT_SKILLS"
grep -qE '^\| [a-z0-9-]+ \|\s*\|$' "$R" && bad "Cline 索引有空 description 的行" || ok
grep -q '\.cline/skills/' "$R" && ok || bad "Cline 索引未指向 .cline/skills/"
cd /; rm -rf "$T"
T=$(mktemp -d); cd "$T"; node "$INS" --tool kilocode >/dev/null 2>&1
R="$T/.kilocode/rules/superpowers-zh.md"
[ -f "$R" ] && ok || bad "Kilo 索引文件未生成"
rows=$(grep -cE '^\| [a-z][a-z0-9-]+ \|' "$R")
[ "$rows" = "$EXPECT_SKILLS" ] && ok || bad "Kilo 索引表 $rows 行,期望 $EXPECT_SKILLS"
grep -q '\.kilocode/skills/' "$R" && ok || bad "Kilo 索引未指向 .kilocode/skills/"
cd /; rm -rf "$T"
T=$(mktemp -d); cd "$T"; node "$INS" --tool kiro >/dev/null 2>&1
R="$T/.kiro/steering/superpowers-zh.md"
[ -f "$R" ] && ok || bad "Kiro steering 索引未生成"
head -2 "$R" | grep -q '^inclusion: always' && ok || bad "Kiro 索引缺 inclusion: always frontmatter"
rows=$(grep -cE '^\| [a-z][a-z0-9-]+ \|' "$R")
[ "$rows" = "$EXPECT_SKILLS" ] && ok || bad "Kiro 索引表 $rows 行,期望 $EXPECT_SKILLS"
grep -q '\.kiro/skills/' "$R" && ok || bad "Kiro 索引未指向 .kiro/skills/"
# 回归守卫steering 每轮常驻里面只能有索引这一个文件。v1.7.9 曾把 20 个 skill
# 正文装在这里 —— 47 个 md、335 KB 每轮进 prompt。别再退回去。
steer_md=$(find "$T/.kiro/steering" -name '*.md' | wc -l | tr -d ' ')
[ "$steer_md" = "1" ] && ok || bad "Kiro steering 下有 $steer_md 个 md只该有索引 1 个)—— 正文不能放常驻目录"
steer_bytes=$(find "$T/.kiro/steering" -name '*.md' -exec cat {} + | wc -c | tr -d ' ')
[ "$steer_bytes" -lt 20000 ] && ok || bad "Kiro steering 常驻 $steer_bytes 字节,超过 20 KB 阈值"
cd /; rm -rf "$T"
# 升级路径:旧布局(正文躺在 steering 下)必须被清掉,否则新旧并存等于没修
T=$(mktemp -d); cd "$T"; mkdir -p .kiro/steering/brainstorming
echo "旧正文" > .kiro/steering/brainstorming/SKILL.md
printf -- '---\ninclusion: always\n---\n我自己的规则\n' > .kiro/steering/my-own.md
node "$INS" --tool kiro >/dev/null 2>&1
[ -d "$T/.kiro/steering/brainstorming" ] && bad "Kiro 升级未清理旧布局 .kiro/steering/<skill>/" || ok
[ -f "$T/.kiro/steering/my-own.md" ] && ok || bad "Kiro 升级误删了用户自己的 steering 文件"
cd /; rm -rf "$T"
# Qwen Codebootstrap 必须写 QWEN.md官方分层记忆的默认上下文文件
# 且项目级/全局装卸都不能留残留。v1.7.10 及更早只装 skills 不写 bootstrap。
T=$(mktemp -d); cd "$T"
printf '# 用户自己的上下文\n' > QWEN.md
node "$INS" --tool qwen >/dev/null 2>&1
grep -q 'superpowers-zh' QWEN.md && ok || bad "Qwen: QWEN.md 未写入 bootstrap"
grep -q '用户自己的上下文' QWEN.md && ok || bad "Qwen: 覆盖了用户已有的 QWEN.md"
grep -q '\.qwen/skills/' QWEN.md && ok || bad "Qwen: bootstrap 未指向 .qwen/skills/"
node "$INS" --uninstall >/dev/null 2>&1
grep -q 'superpowers-zh' QWEN.md && bad "Qwen: 卸载后 QWEN.md 仍残留 superpowers-zh 段" || ok
grep -q '用户自己的上下文' QWEN.md && ok || bad "Qwen: 卸载误删了用户自己的 QWEN.md 内容"
cd /; rm -rf "$T"
H=$(mktemp -d)
HOME="$H" node "$INS" --global --tool qwen >/dev/null 2>&1
[ -f "$H/.qwen/QWEN.md" ] && ok || bad "Qwen --global: 未写 ~/.qwen/QWEN.md"
HOME="$H" node "$INS" --global --uninstall >/dev/null 2>&1
left=$(find "$H" -type f 2>/dev/null | wc -l | tr -d ' ')
[ "$left" = "0" ] && ok || bad "Qwen --global 卸载后 HOME 残留 $left 个文件"
rm -rf "$H"
echo ""
echo "─── F. PATH 探测健壮性issue #48 新代码)───"
T=$(mktemp -d); cd "$T"
out=$(PATH="" "$NODE" "$INS" 2>&1); rc=$?
[ $rc -eq 1 ] && ok || bad "PATH 为空时应退出码 1得到 $rc"
echo "$out" | grep -q "未在当前目录检测到" && ok || bad "PATH 为空时缺少检测落空提示"
out=$(PATH="/nonexistent:::/also/missing" "$NODE" "$INS" 2>&1); rc=$?
[ $rc -eq 1 ] && ok || bad "PATH 含空段/不存在目录时应优雅退出 1得到 $rc"
echo "$out" | grep -qi "error\|Traceback\|ENOENT" && bad "PATH 异常时输出了未捕获错误" || ok
cd /; rm -rf "$T"
echo ""
echo "─── G. 自检:本脚本的覆盖清单不得落后于 installer ───"
# 与 audit.sh Category 5 同一口径TARGETS 条目数 + 1Copilot CLI 与 CC 共用目标)
targets=$(sed -n '/^const TARGETS = \[/,/^\];/p' "$INS" | grep -cE "^ \{ name: '")
expected=$((targets + 1))
if [ "${#SPEC[@]}" = "$expected" ]; then ok; else
bad "A 段只覆盖 ${#SPEC[@]} 款工具installer 支持 ${expected} 款 —— 新工具没进 SPEC 会被静默漏测"
fi
# 比对工具名集合,而不是数条数 —— 一个工具可以有多个检测标记
detect_tools=$(printf '%s\n' "${DETECT[@]}" | sed 's/^[^:]*://' | sort -u)
target_tools=$(sed -n '/^const TARGETS = \[/,/^\];/p' "$INS" | sed -nE "s/^ \{ name: '([^']+)'.*/\1/p" | sort -u)
uncovered=$(comm -13 <(printf '%s\n' "$detect_tools") <(printf '%s\n' "$target_tools"))
if [ -z "$uncovered" ]; then ok; else
bad "B 段未验证这些工具的检测标记: $(printf '%s' "$uncovered" | tr '\n' ' ')"
fi
echo ""
echo "═══════════════════════════════════"
echo "✅ PASS: $PASS ❌ FAIL: $FAIL"
if [ $FAIL -gt 0 ]; then
echo ""
echo "失败项:"
for f in "${FAILURES[@]}"; do echo " - $f"; done
exit 1
fi
echo "✅ 深度验证全部通过"