Two related fixes exposed when install.sh was first staged post-audit: 1. shellcheck invocation in setup-hooks.sh lacked -x, causing SC1091 (info) to fire for any .sh file that sources another, blocking the pre-commit hook on legitimate scripts. 2. The shellcheck source= directive in install.sh pointed to 'deploy-manifest.sh' (bare filename). With -x, shellcheck resolves this from CWD (repo root), where the file doesn't exist. Updated to 'scripts/deploy-manifest.sh' — the correct repo-root-relative path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv5iNACZxumtF2k6TsK18q
217 lines
6.2 KiB
Bash
Executable File
217 lines
6.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Installs git hook blocks for validation into a target repository.
|
|
# Usage: setup-hooks.sh [TARGET_REPO]
|
|
# TARGET_REPO — path to the git repo to configure (default: current directory)
|
|
# Idempotent: safe to re-run; always replaces each managed block with the current version.
|
|
|
|
SHELLCHECK_VERSION="0.10.0"
|
|
JQ_VERSION="1.7.1"
|
|
YQ_VERSION="4.44.3"
|
|
TOOL_INSTALL_DIR="${TOOL_INSTALL_DIR:-/usr/local/bin}"
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
TARGET="${1:-$(pwd)}"
|
|
MARKER="# managed by setup-hooks.sh"
|
|
END_MARKER="# end setup-hooks"
|
|
|
|
if [[ ! -d "$TARGET/.git" ]]; then
|
|
echo "Error: $TARGET is not a git repository" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# --- Tool installation ---
|
|
|
|
_os() {
|
|
case "$(uname -s)" in
|
|
Linux) echo "linux" ;;
|
|
Darwin) echo "darwin" ;;
|
|
*) echo "Error: unsupported OS '$(uname -s)'" >&2; exit 1 ;;
|
|
esac
|
|
}
|
|
|
|
_arch() {
|
|
case "$(uname -m)" in
|
|
x86_64) echo "x86_64" ;;
|
|
aarch64 | arm64) echo "aarch64" ;;
|
|
*) echo "Error: unsupported architecture '$(uname -m)'" >&2; exit 1 ;;
|
|
esac
|
|
}
|
|
|
|
install_shellcheck() (
|
|
os="$(_os)"
|
|
arch="$(_arch)"
|
|
tarball="shellcheck-v${SHELLCHECK_VERSION}.${os}.${arch}.tar.xz"
|
|
url="https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/${tarball}"
|
|
tmp_dir="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp_dir"' EXIT
|
|
echo "Installing shellcheck v${SHELLCHECK_VERSION}..."
|
|
curl -fsSL "$url" -o "$tmp_dir/$tarball"
|
|
tar -xJf "$tmp_dir/$tarball" -C "$tmp_dir" --strip-components=1
|
|
install -m 755 "$tmp_dir/shellcheck" "$TOOL_INSTALL_DIR/shellcheck"
|
|
echo "Installed: $TOOL_INSTALL_DIR/shellcheck"
|
|
)
|
|
|
|
install_jq() (
|
|
os="$(_os)"
|
|
[[ "$os" == "darwin" ]] && os="macos"
|
|
arch="$(_arch | sed 's/x86_64/amd64/; s/aarch64/arm64/')"
|
|
url="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-${os}-${arch}"
|
|
tmp_dir="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp_dir"' EXIT
|
|
echo "Installing jq v${JQ_VERSION}..."
|
|
curl -fsSL "$url" -o "$tmp_dir/jq"
|
|
install -m 755 "$tmp_dir/jq" "$TOOL_INSTALL_DIR/jq"
|
|
echo "Installed: $TOOL_INSTALL_DIR/jq"
|
|
)
|
|
|
|
install_yq() (
|
|
os="$(_os)"
|
|
arch="$(_arch | sed 's/x86_64/amd64/; s/aarch64/arm64/')"
|
|
url="https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_${os}_${arch}"
|
|
tmp_dir="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp_dir"' EXIT
|
|
echo "Installing yq v${YQ_VERSION}..."
|
|
curl -fsSL "$url" -o "$tmp_dir/yq"
|
|
install -m 755 "$tmp_dir/yq" "$TOOL_INSTALL_DIR/yq"
|
|
echo "Installed: $TOOL_INSTALL_DIR/yq"
|
|
)
|
|
|
|
ensure_tool() {
|
|
local tool="$1"
|
|
if ! command -v "$tool" &>/dev/null; then
|
|
"install_${tool}"
|
|
fi
|
|
}
|
|
|
|
ensure_tool shellcheck
|
|
ensure_tool jq
|
|
ensure_tool yq
|
|
|
|
# --- Marker-block helpers ---
|
|
|
|
write_block() {
|
|
local hook_file="$1"
|
|
local block_content="$2"
|
|
|
|
if grep -qF "$MARKER" "$hook_file"; then
|
|
awk -v start="$MARKER" -v end="$END_MARKER" '
|
|
$0 == start { skip=1; next }
|
|
skip && $0 == end { skip=0; next }
|
|
!skip { print }
|
|
' "$hook_file" > "${hook_file}.tmp" && mv "${hook_file}.tmp" "$hook_file"
|
|
fi
|
|
|
|
printf '\n%s\n%s\n%s\n' "$MARKER" "$block_content" "$END_MARKER" >> "$hook_file"
|
|
chmod +x "$hook_file"
|
|
}
|
|
|
|
ensure_hook() {
|
|
local hook_file="$1"
|
|
if [[ ! -f "$hook_file" ]]; then
|
|
printf '#!/usr/bin/env bash\nset -euo pipefail\n' > "$hook_file"
|
|
chmod +x "$hook_file"
|
|
fi
|
|
}
|
|
|
|
# --- commit-msg: conventional commits ---
|
|
|
|
COMMIT_MSG_HOOK="$TARGET/.git/hooks/commit-msg"
|
|
ensure_hook "$COMMIT_MSG_HOOK"
|
|
|
|
commit_msg_block() {
|
|
cat <<'BLOCK'
|
|
msg=$(cat "$1")
|
|
pattern='^(feat|fix|docs|chore|refactor|test|perf|ci|build|revert)(\(.+\))?!?: .+'
|
|
if ! echo "$msg" | grep -qE "$pattern"; then
|
|
echo "ERROR: Commit message must follow Conventional Commits format." >&2
|
|
echo " Examples: feat: add login, fix(auth): correct token expiry, chore!: drop python dep" >&2
|
|
exit 1
|
|
fi
|
|
BLOCK
|
|
}
|
|
|
|
write_block "$COMMIT_MSG_HOOK" "$(commit_msg_block)"
|
|
echo "Updated: $COMMIT_MSG_HOOK (conventional commits check)"
|
|
|
|
# --- pre-commit: shellcheck + jq/yq + SKILL.md frontmatter ---
|
|
|
|
PRE_COMMIT_HOOK="$TARGET/.git/hooks/pre-commit"
|
|
ensure_hook "$PRE_COMMIT_HOOK"
|
|
|
|
pre_commit_block() {
|
|
cat <<'BLOCK'
|
|
staged=$(git diff --cached --name-only --diff-filter=ACM)
|
|
|
|
# shellcheck on staged .sh files
|
|
if command -v shellcheck &>/dev/null; then
|
|
while IFS= read -r f; do
|
|
[[ -f "$f" ]] && shellcheck -x "$f"
|
|
done < <(echo "$staged" | grep '\.sh$' || true)
|
|
else
|
|
echo "Warning: shellcheck not installed — shell script linting skipped" >&2
|
|
fi
|
|
|
|
# jq validation on staged .json files
|
|
if command -v jq &>/dev/null; then
|
|
while IFS= read -r f; do
|
|
[[ -f "$f" ]] && jq . "$f" > /dev/null
|
|
done < <(echo "$staged" | grep '\.json$' || true)
|
|
else
|
|
echo "Warning: jq not installed — JSON validation skipped" >&2
|
|
fi
|
|
|
|
# yq validation on staged .yaml/.yml files
|
|
if command -v yq &>/dev/null; then
|
|
while IFS= read -r f; do
|
|
[[ -f "$f" ]] && yq eval '.' "$f" > /dev/null
|
|
done < <(echo "$staged" | grep -E '\.(yaml|yml)$' || true)
|
|
else
|
|
echo "Warning: yq not installed — YAML validation skipped" >&2
|
|
fi
|
|
|
|
# SKILL.md frontmatter: must have name: and description:
|
|
while IFS= read -r f; do
|
|
if [[ -f "$f" ]]; then
|
|
if ! grep -q '^name:' "$f" || ! grep -q '^description' "$f"; then
|
|
echo "ERROR: $f is missing required frontmatter fields (name: and description:)" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
done < <(echo "$staged" | grep 'SKILL\.md$' || true)
|
|
BLOCK
|
|
}
|
|
|
|
write_block "$PRE_COMMIT_HOOK" "$(pre_commit_block)"
|
|
echo "Updated: $PRE_COMMIT_HOOK (shellcheck + jq/yq + SKILL.md validation)"
|
|
|
|
# --- pre-push: test suite + manifest cross-reference ---
|
|
|
|
PRE_PUSH_HOOK="$TARGET/.git/hooks/pre-push"
|
|
ensure_hook "$PRE_PUSH_HOOK"
|
|
|
|
pre_push_block() {
|
|
local script_dir="$SCRIPT_DIR"
|
|
cat <<BLOCK
|
|
HOOKS_SCRIPT_DIR="$script_dir"
|
|
REPO_ROOT="\$(git rev-parse --show-toplevel)"
|
|
|
|
echo "Running test suite..."
|
|
bash "\$REPO_ROOT/tests/test-install.sh"
|
|
bash "\$REPO_ROOT/tests/test-governance-layer.sh"
|
|
bash "\$REPO_ROOT/tests/test-check-manifests.sh"
|
|
bash "\$REPO_ROOT/tests/test-setup-hooks.sh"
|
|
|
|
echo "Checking manifests..."
|
|
bash "\$HOOKS_SCRIPT_DIR/check-manifests.sh" "\$REPO_ROOT"
|
|
BLOCK
|
|
}
|
|
|
|
write_block "$PRE_PUSH_HOOK" "$(pre_push_block)"
|
|
echo "Updated: $PRE_PUSH_HOOK (test suite + manifest check)"
|
|
|
|
echo ""
|
|
echo "Done. Hooks installed in $TARGET/.git/hooks/"
|
|
echo "To skip on a single push: git push --no-verify"
|