Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions .claude/hooks/install-deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Ensures the current checkout's dependencies are installed. Registered on
# SessionStart and on PostToolUse for EnterWorktree, because fresh cloud clones
# and fresh git worktrees start without node_modules.
set -euo pipefail

# Logs go to stderr because SessionStart hook stdout enters the model's context.
log() { echo "[install-deps] $*" >&2; }

# A hook's non-interactive shell never sources .bashrc, so nvm must be loaded
# here; without it, npm can resolve to a system Node (cloud images ship
# Node 22 at /opt/node22). Sourcing nvm.sh activates the default alias.
# Cloud setup scripts run with HOME=/root, hence the second candidate.
for dir in "${HOME}/.nvm" /root/.nvm; do
if [[ -s "${dir}/nvm.sh" ]]; then
export NVM_DIR="${dir}"
# shellcheck disable=SC1091
. "${dir}/nvm.sh"
break
fi
done

# The payload cwd follows the session into a worktree; CLAUDE_PROJECT_DIR
# stays at the original project root, so it is only the fallback.
payload_cwd="$(node -e 'let d="";process.stdin.on("data",(c)=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).cwd??""))' 2>/dev/null || true)"
checkout="$(git -C "${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" rev-parse --show-toplevel 2>/dev/null)" || {
log "no git checkout resolved from '${payload_cwd:-<empty>}' — skipping"
exit 0
}

# The marker, stamp, and lock live in the checkout's git directory, which is
# per-worktree and never tracked. A .claude/ location would surface them as
# untracked files in any checkout whose .gitignore predates this hook.
state_dir="$(git -C "${checkout}" rev-parse --absolute-git-dir)"

# The marker survives an interrupted npm ci (a hook-timeout kill included), so
# a partial node_modules is retried instead of trusted. It holds the hash of
# the package-lock.json that install used, so recovery never stamps a tree
# built from an older lockfile.
marker="${state_dir}/install-deps-incomplete"

# The stamp records which package-lock.json the hook's own last install used,
# so a stamped checkout reinstalls after a pull changes the lockfile. An
# unstamped checkout (node_modules installed by the developer, not the hook)
# is trusted as-is — the hook must never wipe an install it does not own.
stamp="${state_dir}/install-deps-lockhash"
lockfile_hash="$(git -C "${checkout}" hash-object package-lock.json 2>/dev/null || true)"

dependencies_are_current() {
local stamped
stamped="$(cat "${stamp}" 2>/dev/null || true)"
if [[ ! -d "${checkout}/node_modules" || -f "${marker}" ]]; then
return 1
fi

[[ -z "${stamped}" || "${stamped}" == "${lockfile_hash}" ]]
}

if dependencies_are_current; then
log "node_modules present in ${checkout} — nothing to do"
exit 0
fi

# A kernel lock serializes installs across sessions.
# - The lock belongs to the file opened on fd 9, not to a process. npm ci
# inherits fd 9, so the lock stays held while an orphaned npm ci outlives a
# killed hook, and the kernel releases it once every holder exits. No stale
# lock is ever left for a later session to steal.
# - Perl's flock is the lock call available on both macOS, which lacks
# flock(1), and Linux, which lacks lockf(1). Perl locks this shell's fd 9,
# so the lock outlives the perl process.
# - The 480s wait sits well inside the 600s hook timeout in settings.json.
# Perl exits 75 when that wait times out.
exec 9>>"${state_dir}/install-deps.lock"
Comment thread
aliasunder marked this conversation as resolved.
if command -v perl >/dev/null 2>&1; then
lock_status=0
perl -MFcntl=:flock -e '
open(my $lock, ">&=", 9) or exit 2;
exit 0 if flock($lock, LOCK_EX | LOCK_NB);
print STDERR "[install-deps] another session is installing in $ARGV[0] — waiting for it\n";
$SIG{ALRM} = sub { exit 75 };
alarm 480;
flock($lock, LOCK_EX) or exit 2;
exit 0;
' "${checkout}" || lock_status=$?

if ((lock_status == 75)); then
log "concurrent install still running after 480s in ${checkout} — skipping"
exit 0
fi
if ((lock_status != 0)); then
log "could not take the install lock in ${checkout} (perl exit ${lock_status}) — installing without it"
fi
else
log "perl not found — installing in ${checkout} without the install lock"
fi

# The session that held the lock may have just installed this lockfile.
if dependencies_are_current; then
log "another session finished the install in ${checkout} — nothing to do"
exit 0
fi

# A hook killed by timeout can leave the marker even though its orphaned npm ci
# finished the install. A tree that passes npm ls is complete, so this clears
# the marker and stamps the tree instead of rebuilding it.
# - Holding the lock guarantees no orphaned npm ci is still writing, because
# that npm ci would still hold fd 9.
# - The marker's lockfile hash must match the current one. A tree built from
# an older lockfile passes npm ls whenever package.json ranges still hold,
# and stamping it would hide the lockfile change forever.
marker_lockfile_hash="$(cat "${marker}" 2>/dev/null || true)"
if [[ -d "${checkout}/node_modules" && -f "${marker}" && "${marker_lockfile_hash}" == "${lockfile_hash}" ]]; then
if npm --prefix "${checkout}" ls --depth=0 >/dev/null 2>&1; then
rm -f "${marker}"

# The orphaned install was still the hook's own, so it is stamped like the
# normal success path. Leaving it unstamped would disable the
# lockfile-change guard for this checkout forever.
if [[ -n "${lockfile_hash}" ]]; then
printf '%s\n' "${lockfile_hash}" > "${stamp}"
fi
Comment thread
aliasunder marked this conversation as resolved.
log "marker left by an interrupted hook but the dependency tree in ${checkout} is complete — clearing"
exit 0
fi
fi

cd "${checkout}"
printf '%s\n' "${lockfile_hash}" > "${marker}"

# Honor the checkout's .nvmrc when that Node is already installed; otherwise
# stay on the default alias — a hook must never download a Node version.
command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true
log "installing dependencies in ${checkout} (node $(node --version 2>/dev/null || echo unknown))"

# npm's stdout goes to stderr too, because SessionStart hook stdout enters the
# model's context.
if npm ci >&2; then
rm -f "${marker}"
if [[ -n "${lockfile_hash}" ]]; then
printf '%s\n' "${lockfile_hash}" > "${stamp}"
fi
log "install complete"
else
log "npm ci failed — the session continues without dependencies; the marker forces a retry next session"
fi
exit 0
28 changes: 28 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/install-deps.sh",
"timeout": 600
}
]
}
],
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/install-deps.sh",
"timeout": 600
}
]
}
]
}
}
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ dist/
# Symlinks into the private Obsidian vault (local dev only)
CLAUDE.local.md
TASKS.md

# .claude/ is local state except the committed session hooks. A trailing-slash
# directory ignore would block the re-includes, because git never descends
# into an ignored directory.
.claude/*
!.claude/settings.json
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/install-deps.sh
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ its callers, and pre-existing bugs in traced code are valid findings.
## Structure

```text
.claude/ # committed Claude Code session hooks (the rest of .claude/ is gitignored)
settings.json # SessionStart + PostToolUse(EnterWorktree) → install-deps.sh
hooks/
install-deps.sh # loads nvm, runs npm ci when a fresh clone or worktree has no node_modules
action.yml # action metadata — inputs/outputs, runs.using: docker
Dockerfile # multi-stage: build (tsc) → slim runtime
fixtures/ # test fixtures (event payloads, sample diff, LLM responses)
Expand Down
Loading