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
This commit is contained in:
159
tests/test-apm-current-hook.sh
Executable file
159
tests/test-apm-current-hook.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/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 ]]
|
||||
@@ -40,11 +40,25 @@ DEPLOY_EXECUTABLES=()
|
||||
DEPLOY_DIRS=()
|
||||
EOF
|
||||
|
||||
# Copy the real install.sh and git-hooks into the temp repo
|
||||
# Copy the real install.sh into the temp repo.
|
||||
cp "$REPO_ROOT/scripts/install.sh" "$TEMP_REPO/scripts/install.sh"
|
||||
cp -r "$REPO_ROOT/scripts/git-hooks" "$TEMP_REPO/scripts/git-hooks"
|
||||
|
||||
HOOKS_SRC="$TEMP_REPO/scripts/git-hooks"
|
||||
mkdir -p "$HOOKS_SRC"
|
||||
|
||||
# Copy whatever real hooks exist, then add a synthetic fixture. The repo
|
||||
# currently ships none — the only entry was `post-push`, removed once it was
|
||||
# found that git has no such client-side hook, so it had never fired (see
|
||||
# ADR-0019). install.sh's copy block is generic and stays worth testing, so the
|
||||
# fixture keeps that coverage alive independently of whether any real hook
|
||||
# happens to exist. Real hooks are still picked up by the loops below.
|
||||
if [[ -d "$REPO_ROOT/scripts/git-hooks" ]]; then
|
||||
find "$REPO_ROOT/scripts/git-hooks" -maxdepth 1 -type f -exec cp {} "$HOOKS_SRC/" \;
|
||||
fi
|
||||
|
||||
FIXTURE_HOOK="fixture-hook"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$HOOKS_SRC/$FIXTURE_HOOK"
|
||||
chmod +x "$HOOKS_SRC/$FIXTURE_HOOK"
|
||||
|
||||
run_install() {
|
||||
HOME="$TEMP_HOME" bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1
|
||||
@@ -108,7 +122,7 @@ OTHER_REPO="$(mktemp -d)"
|
||||
git -C "$OTHER_REPO" init -q
|
||||
if HOME="$TEMP_HOME" GIT_DIR="$OTHER_REPO/.git" GIT_WORK_TREE="$OTHER_REPO" \
|
||||
bash "$TEMP_REPO/scripts/install.sh" > /dev/null 2>&1; then
|
||||
if [[ -f "$TEMP_REPO/.git/hooks/post-push" ]]; then
|
||||
if [[ -f "$TEMP_REPO/.git/hooks/$FIXTURE_HOOK" ]]; then
|
||||
pass "resolves \$TEMP_REPO/.git/hooks/ even with inherited GIT_DIR"
|
||||
else
|
||||
fail "installed into inherited GIT_DIR instead of \$TEMP_REPO"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
HOOK="$REPO_ROOT/scripts/git-hooks/post-push"
|
||||
FAKE_BIN="$(mktemp -d)"
|
||||
FAKE_HOME="$(mktemp -d)"
|
||||
trap 'rm -rf "$FAKE_BIN" "$FAKE_HOME"' EXIT
|
||||
|
||||
# Helper: write a fake git stub that exits with the given code
|
||||
make_git() {
|
||||
local exit_code="$1"
|
||||
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/git"
|
||||
chmod +x "$FAKE_BIN/git"
|
||||
}
|
||||
|
||||
# Helper: write a fake claude stub; optionally touches a sentinel file on invocation
|
||||
make_claude() {
|
||||
local exit_code="$1"
|
||||
local log="${2:-}"
|
||||
if [[ -n "$log" ]]; then
|
||||
printf '#!/usr/bin/env bash\ntouch "%s"\nexit %s\n' "$log" "$exit_code" > "$FAKE_BIN/claude"
|
||||
else
|
||||
printf '#!/usr/bin/env bash\nexit %s\n' "$exit_code" > "$FAKE_BIN/claude"
|
||||
fi
|
||||
chmod +x "$FAKE_BIN/claude"
|
||||
}
|
||||
|
||||
# Run the hook with mocked PATH and HOME; suppress all output
|
||||
run_hook() {
|
||||
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
# Run the hook and capture combined stdout+stderr
|
||||
capture_hook() {
|
||||
PATH="$FAKE_BIN:$PATH" HOME="$FAKE_HOME" bash "$HOOK" 2>&1 || true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "--- post-push: always exits 0 ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 1; make_claude 0
|
||||
if run_hook; then
|
||||
pass "exits 0 when git pull fails"
|
||||
else
|
||||
fail "should exit 0 when git pull fails"
|
||||
fi
|
||||
|
||||
make_git 0; make_claude 1
|
||||
if run_hook; then
|
||||
pass "exits 0 when claude plugin update fails"
|
||||
else
|
||||
fail "should exit 0 when claude plugin update fails"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: success output ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 0; make_claude 0
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -q "kyberforge cache updated" \
|
||||
&& pass "prints success message when both git pull and claude succeed" \
|
||||
|| fail "should print success message when both commands succeed"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: warnings on failure ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
make_git 1; make_claude 0
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -qi "failed to pull" \
|
||||
&& pass "warns when git pull fails" \
|
||||
|| fail "should warn when git pull fails"
|
||||
|
||||
make_git 0; make_claude 1
|
||||
output=$(capture_hook)
|
||||
echo "$output" | grep -qi "failed" \
|
||||
&& pass "warns when claude plugin update fails" \
|
||||
|| fail "should warn when claude plugin update fails"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "--- post-push: claude not called when git pull fails ---"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CLAUDE_LOG="$FAKE_HOME/claude-was-called"
|
||||
make_git 1; make_claude 0 "$CLAUDE_LOG"
|
||||
run_hook
|
||||
if [[ ! -f "$CLAUDE_LOG" ]]; then
|
||||
pass "claude not called when git pull fails"
|
||||
else
|
||||
fail "claude should not be called when git pull fails"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
[[ $FAIL -eq 0 ]]
|
||||
Reference in New Issue
Block a user