Why --- ADR-0018 left deployed skills tracking the remote default branch with nothing watching for drift. The mechanism that was supposed to cover this, scripts/git-hooks/post-push, could never have worked: git has no client-side post-push hook. install.sh copied it into .git/hooks/ so it looked installed, and it had never once fired. Issue #78 reported it as skipping the gitea plugin; it was skipping everything. Refreshing on push was also the wrong shape. Your install goes stale when someone else merges, so a push of your own is neither necessary nor sufficient for staleness to have occurred. Implementation notes -------------------- kyberforge ships a SessionStart hook (startup matcher only) that runs `apm outdated`, and when anything is behind runs `apm update --yes` and returns reloadSkills:true so the running session picks up redeployed content. It exits silently with no apm.lock.yaml present, which keeps it inert for hosts that installed this plugin natively rather than through apm. Two findings drove the wiring, both verified rather than assumed: - apm resolves ${CLAUDE_PLUGIN_ROOT} against the installed package root, and `apm pack` keeps only *.json from .apm/hooks/. A .../hooks/<script> reference therefore points into the generated mirror where the script does not exist — apm reports "Hook script not found" and deploys a hook aimed at nothing. The reference must be .apm/-relative, and a test pins it. - apm's executable-trust gate is OFF unless apm.yml carries an `executables:` block; until now every hook, bin and MCP primitive a dependency shipped would have deployed unprompted. Root apm.yml now enables it. The allow key is version-pinned by apm's design, so a kyberforge version bump silently blocks the hook until the key is bumped too — called out in the block and the ADR. Also corrects ADR-0018 and AGENTS.md, which named `apm install` as the refresh command. It is not: `apm install` deploys from apm.lock.yaml's pinned commit and does not re-resolve refs. `apm update` does. Impact ------ Session startup costs ~0.7s when current and ~10.4s when six packages are behind. Auto-refresh rewrites apm.lock.yaml, so an unexplained modification to it after opening a session is expected; the emitted notice says so. .claude/settings.json stops being exactly {"hooks": {}} once the hook lands there — the merged entry is apm's own output, and the rule that nothing repo-authored goes in that file is unchanged. .claude/hooks/ and the .claude/apm-hooks.json sidecar are gitignored install output. The hook cannot install itself: dependencies resolve from the remote, so it takes effect only after this merges and `apm update` runs once against the new default branch. scripts/git-hooks/ is now empty. install.sh's copy block is kept and test-git-hooks-install.sh synthesizes its own fixture, so the mechanism stays tested without requiring a dead hook to exist. ADR: 0019 Refs: #78 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X7GvKuJfy2WrdBmUttV4DT
160 lines
7.5 KiB
Bash
Executable File
160 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Tests for plugins/kyberforge/.apm/hooks/check-apm-current.sh — the SessionStart
|
|
# hook that keeps an apm-consumed install level with its remote.
|
|
#
|
|
# `apm` is mocked throughout: the hook's contract is "read `apm outdated`, decide,
|
|
# emit SessionStart JSON", and that is testable without a network or a real
|
|
# install.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
HOOK="$REPO_ROOT/plugins/kyberforge/.apm/hooks/check-apm-current.sh"
|
|
HOOKS_JSON="$REPO_ROOT/plugins/kyberforge/.apm/hooks/hooks.json"
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
|
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
|
|
|
command -v python3 > /dev/null 2>&1 || { echo "python3 required"; exit 77; }
|
|
|
|
FAKE_BIN="$(mktemp -d)"
|
|
WORK="$(mktemp -d)"
|
|
trap 'rm -rf "$FAKE_BIN" "$WORK"' EXIT
|
|
|
|
# Mock `apm`. $1 chooses what `apm outdated` reports; $2 the exit code of
|
|
# `apm update`. A sentinel file records whether update was actually invoked.
|
|
make_apm() {
|
|
local outdated_line="$1" update_exit="$2"
|
|
cat > "$FAKE_BIN/apm" << EOF
|
|
#!/usr/bin/env bash
|
|
case "\$1" in
|
|
outdated) echo "$outdated_line"; exit 0 ;;
|
|
update) touch "$WORK/update-was-called"; exit $update_exit ;;
|
|
esac
|
|
exit 0
|
|
EOF
|
|
chmod +x "$FAKE_BIN/apm"
|
|
}
|
|
|
|
run_hook() { (cd "$WORK" && PATH="$FAKE_BIN:$PATH" bash "$HOOK" 2>/dev/null); }
|
|
|
|
json_field() { python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"][sys.argv[1]])' "$1"; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo "--- inert without an apm-consumed install ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
make_apm "[!] 6 outdated dependencies found" 0
|
|
rm -f "$WORK/apm.lock.yaml" "$WORK/update-was-called"
|
|
out="$(run_hook)"; rc=$?
|
|
[[ $rc -eq 0 ]] && pass "exits 0 with no apm.lock.yaml" || fail "should exit 0 with no apm.lock.yaml"
|
|
[[ -z "$out" ]] && pass "emits nothing with no apm.lock.yaml" || fail "should stay silent with no apm.lock.yaml"
|
|
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update with no apm.lock.yaml" \
|
|
|| fail "must not touch a project that does not use apm"
|
|
|
|
# A native (non-apm) install of this plugin hits exactly this path, so it is the
|
|
# guard that keeps the hook from acting on someone else's repo.
|
|
touch "$WORK/apm.lock.yaml"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- inert when apm is absent ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
rm -f "$WORK/update-was-called"
|
|
out="$( (cd "$WORK" && PATH="$(dirname "$(command -v bash)")" bash "$HOOK" 2>/dev/null) )"; rc=$?
|
|
[[ $rc -eq 0 ]] && pass "exits 0 when apm is not on PATH" || fail "should exit 0 when apm is missing"
|
|
[[ -z "$out" ]] && pass "emits nothing when apm is not on PATH" || fail "should stay silent when apm is missing"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- install already current ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
make_apm "[*] All dependencies are up-to-date" 0
|
|
rm -f "$WORK/update-was-called"
|
|
out="$(run_hook)"; rc=$?
|
|
[[ $rc -eq 0 ]] && pass "exits 0 when current" || fail "should exit 0 when current"
|
|
[[ -z "$out" ]] && pass "emits nothing when current" || fail "should stay silent when current"
|
|
[[ ! -f "$WORK/update-was-called" ]] && pass "does not run apm update when current" \
|
|
|| fail "must not update when nothing is stale"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- stale, refresh succeeds ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
make_apm "[!] 6 outdated dependencies found" 0
|
|
rm -f "$WORK/update-was-called"
|
|
out="$(run_hook)"; rc=$?
|
|
[[ $rc -eq 0 ]] && pass "exits 0 when stale" || fail "should exit 0 when stale"
|
|
[[ -f "$WORK/update-was-called" ]] && pass "runs apm update when stale" || fail "should run apm update when stale"
|
|
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
|
pass "emits valid JSON"
|
|
[[ "$(echo "$out" | json_field hookEventName)" == "SessionStart" ]] \
|
|
&& pass "declares hookEventName SessionStart" || fail "wrong hookEventName"
|
|
[[ "$(echo "$out" | json_field reloadSkills)" == "True" ]] \
|
|
&& pass "asks the host to reload skills after a successful refresh" || fail "reloadSkills should be true"
|
|
echo "$out" | json_field additionalContext | grep -q "6 package" \
|
|
&& pass "reports the stale package count" || fail "should report the count"
|
|
# The lockfile rewrite is the surprising part of auto-updating; the notice has
|
|
# to say so or a dirty worktree looks like something else went wrong.
|
|
echo "$out" | json_field additionalContext | grep -q "apm.lock.yaml" \
|
|
&& pass "warns that apm.lock.yaml was rewritten" || fail "should warn about the lockfile rewrite"
|
|
else
|
|
fail "emits valid JSON"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- stale, refresh fails ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
make_apm "[!] 3 outdated dependencies found" 1
|
|
out="$(run_hook)"; rc=$?
|
|
[[ $rc -eq 0 ]] && pass "exits 0 when the refresh fails" || fail "must never fail the session start"
|
|
if echo "$out" | python3 -m json.tool > /dev/null 2>&1; then
|
|
pass "emits valid JSON on failure"
|
|
[[ "$(echo "$out" | json_field reloadSkills)" == "False" ]] \
|
|
&& pass "does not ask for a skill reload when nothing was deployed" || fail "reloadSkills should be false"
|
|
echo "$out" | json_field additionalContext | grep -q "apm update" \
|
|
&& pass "tells the reader how to refresh by hand" || fail "should name the manual command"
|
|
else
|
|
fail "emits valid JSON on failure"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- unparseable count degrades instead of breaking the JSON ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
make_apm "[!] lots of outdated dependencies found" 0
|
|
out="$(run_hook)"
|
|
echo "$out" | python3 -m json.tool > /dev/null 2>&1 \
|
|
&& pass "still emits valid JSON when the count cannot be parsed" || fail "JSON broke on an unparseable count"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "--- hooks.json wiring ---"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# apm resolves script paths relative to the package root, and `apm pack` keeps
|
|
# only *.json from .apm/hooks/ — so a ${CLAUDE_PLUGIN_ROOT}/hooks/... reference
|
|
# points at a directory the script never reaches. It must be .apm/-relative.
|
|
referenced="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["hooks"][0]["command"])' "$HOOKS_JSON")"
|
|
[[ "$referenced" == '${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh' ]] \
|
|
&& pass "hooks.json references the script at its .apm/ path" \
|
|
|| fail "hooks.json references '$referenced' — must be \${CLAUDE_PLUGIN_ROOT}/.apm/hooks/check-apm-current.sh"
|
|
|
|
[[ -x "$HOOK" ]] && pass "hook script is executable" || fail "hook script must be executable"
|
|
|
|
matcher="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["hooks"]["SessionStart"][0]["matcher"])' "$HOOKS_JSON")"
|
|
[[ "$matcher" == "startup" ]] && pass "fires on startup only" \
|
|
|| fail "matcher is '$matcher' — resume/clear/compact would re-run this every compaction"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
echo ""
|
|
echo "Results: $PASS passed, $FAIL failed"
|
|
[[ $FAIL -eq 0 ]]
|