diff options
Diffstat (limited to 'scripts/install-lang.sh')
| -rwxr-xr-x | scripts/install-lang.sh | 129 |
1 files changed, 120 insertions, 9 deletions
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh index 0fc9ea8..3aaa76e 100755 --- a/scripts/install-lang.sh +++ b/scripts/install-lang.sh @@ -33,14 +33,87 @@ fi # Resolve to absolute path PROJECT="$(cd "$PROJECT" && pwd)" +# 0. Cross-bundle collision guard. +# +# Several bundles ship a file at the same path. gitignore-add.txt is appended +# and deduped, and CLAUDE.md is seed-only, so both compose across bundles. Three +# do not: claude/settings.json and githooks/* are installed with `cp -rT` +# (always overwrite), and coverage-makefile.txt is seeded under a fixed name. So +# installing a second bundle replaced the first's hook wiring and pre-commit +# while printing [ok], so a project could lose its paren check or secret scan +# and read the output as success. Refuse instead, naming what would go. +# +# Detection is by rule fingerprint, matching sync-language-bundle.sh: a project +# has bundle X iff one of X's own rule files is in .claude/rules/. No marker +# file, so this works on installs that predate the guard. +project_has_bundle() { + local b="$1" rf + for rf in "$REPO_ROOT/languages/$b/claude/rules"/*.md; do + [ -f "$rf" ] || continue + [ -f "$PROJECT/.claude/rules/$(basename "$rf")" ] && return 0 + done + return 1 +} + +# Files $1's bundle and $LANG both ship, and that install would overwrite. +shared_overwritten_files() { + local other="$1" rel + for rel in claude/settings.json coverage-makefile.txt; do + [ -f "$SRC/$rel" ] && [ -f "$REPO_ROOT/languages/$other/$rel" ] \ + && echo " $(printf '%s' "$rel" | sed 's|^claude/|.claude/|')" + done + if [ -d "$SRC/githooks" ] && [ -d "$REPO_ROOT/languages/$other/githooks" ]; then + for rel in "$SRC/githooks"/*; do + [ -f "$rel" ] || continue + [ -f "$REPO_ROOT/languages/$other/githooks/$(basename "$rel")" ] \ + && echo " githooks/$(basename "$rel")" + done + fi +} + +if [ "$FORCE" != "1" ]; then + collisions="" + for other_dir in "$REPO_ROOT/languages"/*/; do + other="$(basename "$other_dir")" + [ "$other" = "$LANG" ] && continue + [ -d "$other_dir/claude/rules" ] || continue + project_has_bundle "$other" || continue + files="$(shared_overwritten_files "$other")" + [ -n "$files" ] && collisions="${collisions}The '$other' bundle is already installed here. Installing '$LANG' would replace: +${files} +" + done + + if [ -n "$collisions" ]; then + { + echo "ERROR: bundle collision. Refusing to install '$LANG' into $PROJECT" + echo + printf '%s' "$collisions" + echo "Both bundles ship these files, and installing overwrites rather than merges," + echo "so the bundle already here would silently lose them." + echo + echo "Whether a project can carry two bundles at once is an open question." + echo "Until it's settled, install one bundle per project." + echo + echo "To override: re-run with FORCE=1. That also re-seeds CLAUDE.md from the" + echo "bundle template, which overwrites any project-specific edits to it." + } >&2 + exit 1 + fi +fi + echo "Installing '$LANG' ruleset into $PROJECT" -# 1. Generic rules from claude-rules/ (shared across all languages) -if [ -d "$REPO_ROOT/claude-rules" ]; then - mkdir -p "$PROJECT/.claude/rules" - cp "$REPO_ROOT/claude-rules"/*.md "$PROJECT/.claude/rules/" 2>/dev/null || true - count=$(ls -1 "$REPO_ROOT/claude-rules"/*.md 2>/dev/null | wc -l) - echo " [ok] .claude/rules/ — $count generic rule(s) from claude-rules/" +# 1. Generic rules are NOT copied here. They install once at ~/.claude/rules/ +# via `make install` and load in every session on the machine. Copying them per +# project loaded them twice, and project rules outrank user-level ones — so a +# stale project copy quietly overrode the fresh global rule. The bundle owns its +# own language rules only; sync-language-bundle.sh sweeps copies left by earlier +# installs. A machine that wants the generic rules runs `make install`. +mkdir -p "$PROJECT/.claude/rules" +if [ ! -d "$HOME/.claude/rules" ]; then + echo " [!!] ~/.claude/rules/ is missing — run 'make install' so the generic" + echo " rules are available; this bundle installs language rules only." fi # 2. .claude/ — language-specific rules, hooks, settings (authoritative, always overwrite) @@ -66,13 +139,26 @@ if [ -d "$SRC/githooks" ]; then fi fi -# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1 +# 3. CLAUDE.md — seed on first install, don't overwrite unless FORCE=1. +# Prefer the bundle's own template; fall back to the language-neutral +# default so a bundle that ships none still seeds an accurate, non- +# mislabeling header instead of nothing. The default names no language, +# so multi-bundle and wrong-bundle installs don't inherit a false header. +CLAUDE_SRC="" +CLAUDE_KIND="" if [ -f "$SRC/CLAUDE.md" ]; then + CLAUDE_SRC="$SRC/CLAUDE.md" + CLAUDE_KIND="$LANG" +elif [ -f "$REPO_ROOT/languages/default-CLAUDE.md" ]; then + CLAUDE_SRC="$REPO_ROOT/languages/default-CLAUDE.md" + CLAUDE_KIND="language-neutral default" +fi +if [ -n "$CLAUDE_SRC" ]; then if [ -f "$PROJECT/CLAUDE.md" ] && [ "$FORCE" != "1" ]; then echo " [skip] CLAUDE.md already exists (use FORCE=1 to overwrite)" else - cp "$SRC/CLAUDE.md" "$PROJECT/CLAUDE.md" - echo " [ok] CLAUDE.md installed" + cp "$CLAUDE_SRC" "$PROJECT/CLAUDE.md" + echo " [ok] CLAUDE.md installed ($CLAUDE_KIND)" fi fi @@ -113,5 +199,30 @@ if [ -f "$SRC/gitignore-add.txt" ]; then fi fi +# --- Bundle completeness check --- +# Every component copy above is guarded by a plain existence test, so a bundle +# missing one installs silently and reports success. That is how the python and +# typescript bundles shipped for nearly two months with no pre-commit hook, and +# therefore no credential scan, on every project that installed them: nothing +# ever said the component was absent. +# +# This can't be fixed by never missing a component — someone adding the seventh +# bundle will miss one too. It's fixed by the install saying so. Warn, never +# block: a partial bundle is still worth installing, and turning this into a +# failure would just teach people to skip the installer. +missing="" +[ -f "$SRC/githooks/pre-commit" ] || missing="${missing} - githooks/pre-commit (secret scan on commit)"$'\n' +[ -f "$SRC/claude/settings.json" ] || missing="${missing} - claude/settings.json (permissions + PostToolUse hook wiring)"$'\n' +ls "$SRC"/claude/hooks/*.sh >/dev/null 2>&1 || missing="${missing} - claude/hooks/*.sh (validate-on-edit hook)"$'\n' +[ -f "$SRC/CLAUDE.md" ] || missing="${missing} - CLAUDE.md (seed project instructions)"$'\n' + +if [ -n "$missing" ]; then + echo "" + echo "WARNING: the '$LANG' bundle is incomplete. Not installed, because the bundle doesn't ship them:" >&2 + printf '%s' "$missing" >&2 + echo "The install above succeeded; these components are simply absent upstream." >&2 + echo "Add them under languages/$LANG/ in rulesets, then re-run this install." >&2 +fi + echo "" echo "Install complete." |
