Files
holocron/tests/test-apm-current-hook.sh
Defame1297 dee56c506a feat(kyberforge): refresh the apm install at SessionStart, not at push
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
2026-08-14 17:46:38 +00:00

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 ]]