Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).
Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
150 lines
4.9 KiB
Bash
Executable File
150 lines
4.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# install_hooks.sh — install a custodian post-commit sync hook into registered repos.
|
||
#
|
||
# Usage:
|
||
# ./install_hooks.sh --repo <slug> # install into one repo
|
||
# ./install_hooks.sh --all # install into all registered repos
|
||
# ./install_hooks.sh --repo <slug> --remove # remove hook from one repo
|
||
# ./install_hooks.sh --all --remove # remove hook from all repos
|
||
#
|
||
# The hook runs `make fix-consistency REPO=<slug>` in the state-hub after each
|
||
# commit, keeping the hub in sync with workplan file changes automatically.
|
||
#
|
||
# Idempotent: the hook block is guarded by a marker comment. Running twice is safe.
|
||
|
||
set -euo pipefail
|
||
|
||
STATEHUB_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||
API_BASE="${STATE_HUB_API:-http://127.0.0.1:8000}"
|
||
MARKER="# custodian-sync-hook"
|
||
|
||
usage() {
|
||
echo "Usage: $0 --repo <slug> | --all [--remove]"
|
||
exit 1
|
||
}
|
||
|
||
# ── Arg parsing ───────────────────────────────────────────────────────────────
|
||
REPO_SLUG=""
|
||
DO_ALL=false
|
||
REMOVE=false
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--repo) REPO_SLUG="$2"; shift 2 ;;
|
||
--all) DO_ALL=true; shift ;;
|
||
--remove) REMOVE=true; shift ;;
|
||
-h|--help) usage ;;
|
||
*) echo "Unknown argument: $1"; usage ;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "$REPO_SLUG" && "$DO_ALL" == false ]]; then usage; fi
|
||
|
||
# ── Helper: resolve local path for a repo slug ───────────────────────────────
|
||
resolve_path() {
|
||
local slug="$1"
|
||
# Try the registered local_path first
|
||
local api_path
|
||
api_path=$(curl -sf "${API_BASE}/repos/${slug}" | python3 -c \
|
||
"import json,sys; d=json.load(sys.stdin); print(d.get('local_path') or '')" 2>/dev/null || true)
|
||
if [[ -n "$api_path" && -d "$api_path" ]]; then
|
||
echo "$api_path"
|
||
return
|
||
fi
|
||
# Fall back to convention: /home/<user>/<slug>
|
||
local conventional="/home/$(whoami)/${slug}"
|
||
if [[ -d "$conventional" ]]; then
|
||
echo "$conventional"
|
||
return
|
||
fi
|
||
echo ""
|
||
}
|
||
|
||
# ── Helper: install hook into one repo ───────────────────────────────────────
|
||
install_hook() {
|
||
local slug="$1"
|
||
local repo_path
|
||
repo_path=$(resolve_path "$slug")
|
||
if [[ -z "$repo_path" ]]; then
|
||
echo " ⚠ $slug: no local path found — skipping"
|
||
return
|
||
fi
|
||
if [[ ! -d "$repo_path/.git" ]]; then
|
||
echo " ⚠ $slug: $repo_path is not a git repo — skipping"
|
||
return
|
||
fi
|
||
|
||
local hook_file="$repo_path/.git/hooks/post-commit"
|
||
local hook_block
|
||
hook_block=$(cat <<BLOCK
|
||
${MARKER} — managed by custodian, do not edit this block
|
||
if curl -sf ${API_BASE}/state/health >/dev/null 2>&1; then
|
||
(cd "${STATEHUB_DIR}" && make fix-consistency REPO=${slug} >/dev/null 2>&1 &)
|
||
fi
|
||
${MARKER}-end
|
||
BLOCK
|
||
)
|
||
|
||
if [[ -f "$hook_file" ]] && grep -q "$MARKER" "$hook_file"; then
|
||
echo " ✓ $slug: hook already present at $hook_file"
|
||
return
|
||
fi
|
||
|
||
if [[ -f "$hook_file" ]]; then
|
||
# Prepend to existing hook
|
||
local existing
|
||
existing=$(cat "$hook_file")
|
||
printf '#!/usr/bin/env bash\n%s\n\n%s\n' "$hook_block" "$existing" > "$hook_file"
|
||
else
|
||
printf '#!/usr/bin/env bash\n%s\n' "$hook_block" > "$hook_file"
|
||
fi
|
||
chmod +x "$hook_file"
|
||
echo " ✅ $slug: hook installed at $hook_file"
|
||
}
|
||
|
||
# ── Helper: remove hook from one repo ────────────────────────────────────────
|
||
remove_hook() {
|
||
local slug="$1"
|
||
local repo_path
|
||
repo_path=$(resolve_path "$slug")
|
||
if [[ -z "$repo_path" || ! -f "$repo_path/.git/hooks/post-commit" ]]; then
|
||
echo " – $slug: no hook file found — skipping"
|
||
return
|
||
fi
|
||
|
||
local hook_file="$repo_path/.git/hooks/post-commit"
|
||
if ! grep -q "$MARKER" "$hook_file"; then
|
||
echo " – $slug: custodian marker not found in hook — skipping"
|
||
return
|
||
fi
|
||
|
||
# Remove the marked block (between MARKER and MARKER-end inclusive)
|
||
python3 - "$hook_file" <<'PY'
|
||
import sys, re
|
||
path = sys.argv[1]
|
||
text = open(path).read()
|
||
cleaned = re.sub(
|
||
r'# custodian-sync-hook.*?# custodian-sync-hook-end\n?',
|
||
'',
|
||
text,
|
||
flags=re.DOTALL,
|
||
)
|
||
open(path, 'w').write(cleaned)
|
||
PY
|
||
echo " 🗑 $slug: hook block removed from $hook_file"
|
||
}
|
||
|
||
# ── Collect repo slugs ────────────────────────────────────────────────────────
|
||
if $DO_ALL; then
|
||
mapfile -t SLUGS < <(curl -sf "${API_BASE}/repos/" | python3 -c \
|
||
"import json,sys; [print(r['slug']) for r in json.load(sys.stdin) if r.get('status') == 'active']")
|
||
else
|
||
SLUGS=("$REPO_SLUG")
|
||
fi
|
||
|
||
echo "Custodian sync hook — $( $REMOVE && echo 'removing' || echo 'installing' ) for ${#SLUGS[@]} repo(s)"
|
||
for slug in "${SLUGS[@]}"; do
|
||
if $REMOVE; then remove_hook "$slug"; else install_hook "$slug"; fi
|
||
done
|
||
echo "Done."
|