From a670c4a625205a754d8457e9ec9a1ce33c9f1628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20P=C4=99dzim=C4=85=C5=BC?= Date: Sun, 3 May 2026 12:26:38 +0200 Subject: [PATCH 1/4] fix: idempotent fzf symlink + post-install shadow detection (v1.4.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a class of broken installation where _install_fzf short-circuited before creating ~/.local/bin/fzf whenever ~/.fzf was already present from a previous install. With the symlink missing, an unmanaged /usr/local/bin/fzf (older than 0.48) won PATH lookup, and modern fzf shell integration (`source <(fzf --zsh)` in ~/.fzf.zsh) errored with "unknown option: --zsh" on every shell startup. Changes: - modules/base.sh: _install_fzf now reconciles the ~/.local/bin/fzf symlink unconditionally — clone-skip no longer skips symlink creation. - lib/utils.sh: new verify_managed_binaries helper that simulates the user's interactive zsh PATH and warns when an unmanaged binary shadows a managed ~/.local/bin tool. Quiet on a clean system. - install.sh + update.sh: invoke the verifier as a final read-only step. Complements _check_path_shadows (which only covered /usr/local/bin tools nvim/xcape) by catching the inverse failure mode for ~/.local/bin tools. - test.sh: strict resolution check for fzf — verifies symlink target and that command -v fzf resolves to a managed location. Catches this regression class before it ships. --- CHANGELOG.md | 26 +++++++++++++++++++++++++ VERSION | 2 +- install.sh | 12 ++++++++++++ lib/utils.sh | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ modules/base.sh | 28 +++++++++++++++++---------- test.sh | 17 +++++++++++++++++ update.sh | 14 ++++++++++++++ 7 files changed, 138 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0163f66..6cc4f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.1] - 2026-05-03 + +### Fixed +- `modules/base.sh` `_install_fzf`: short-circuited before creating + `~/.local/bin/fzf` whenever `~/.fzf` was already present from a previous + install. With the symlink missing, any older unmanaged `/usr/local/bin/fzf` + (e.g. left over from a prior manual install) won PATH lookup. Modern + `~/.fzf.zsh` shell integration emits `source <(fzf --zsh)`, which the older + binary rejects with `unknown option: --zsh` on every shell startup. The + installer is now idempotent: it always reconciles `~/.local/bin/fzf` → + `~/.fzf/bin/fzf`, even when the clone already exists. + +### Added +- `lib/utils.sh` `verify_managed_binaries`: post-install / post-update check + that simulates the user's interactive zsh PATH and warns when a managed + `~/.local/bin/` binary is shadowed by an unmanaged binary elsewhere on + PATH (or is missing entirely). Covers `fzf`, `rg`, `fd`, `jq`, `zoxide`, + `delta`, `eza`. Quiet on a clean system. +- `install.sh`: invokes `verify_managed_binaries` after install completes. +- `update.sh`: invokes `verify_managed_binaries` alongside the existing + `_check_path_shadows` (which only covered `/usr/local/bin/{nvim,xcape}` — + the inverse failure mode for `~/.local/bin` tools was previously uncaught). +- `test.sh`: strict resolution check for `fzf` — verifies `~/.local/bin/fzf` + symlink target and that `command -v fzf` resolves to a managed location. + Catches the regression class above before it ships. + ## [1.4.0] - 2026-04-13 ### Added diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/install.sh b/install.sh index f4ba075..d39c777 100755 --- a/install.sh +++ b/install.sh @@ -115,6 +115,18 @@ case "$PROFILE" in *) die "Unknown profile: '$PROFILE'. Valid options: minimal | workstation | docker" ;; esac +# Catch broken installations where an unmanaged system binary shadows a managed +# install (e.g. a stale /usr/local/bin/fzf masking ~/.local/bin/fzf). Quiet on +# clean systems; loud when something is wrong. +verify_managed_binaries \ + fzf "$HOME/.local/bin/fzf" \ + rg "$HOME/.local/bin/rg" \ + fd "$HOME/.local/bin/fd" \ + jq "$HOME/.local/bin/jq" \ + zoxide "$HOME/.local/bin/zoxide" \ + delta "$HOME/.local/bin/delta" \ + eza "$HOME/.local/bin/eza" + echo "" log_ok "Setup complete!" echo "" diff --git a/lib/utils.sh b/lib/utils.sh index bb09581..9b81255 100755 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -281,6 +281,56 @@ _verify_dest() { fi } +# Verify managed binaries resolve to their expected install path. +# Catches PATH shadowing by unmanaged binaries (e.g. an old /usr/local/bin/fzf +# left over from a previous install masking ~/.local/bin/fzf when the symlink +# is missing). Silent on a clean system; warns when a shadow is detected. +# +# We approximate the user's interactive zsh PATH (~/.zshrc puts ~/.local/bin +# ahead of system dirs) so the check predicts what shell startup will resolve, +# not what install-time bash sees. +# +# Usage: verify_managed_binaries TOOL1 PATH1 TOOL2 PATH2 ... +verify_managed_binaries() { + log_step "Verify managed binaries on PATH" + + local _orig_path="$PATH" + # Mirror .zshrc line 7 ordering. Harmless if already in $PATH. + export PATH="$HOME/go/bin:$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH" + + local _any_issue=false _any_checked=false + while [ "$#" -ge 2 ]; do + local _tool="$1" _want="$2" + shift 2 + # Tool not installed at managed location → not our concern (skip silently). + # Use -e so a valid symlink to a missing target is still flagged below. + [ -e "$_want" ] || [ -L "$_want" ] || continue + _any_checked=true + + local _active + _active=$(command -v "$_tool" 2>/dev/null || true) + if [ -z "$_active" ]; then + log_warn "$_tool: installed at $_want but not on PATH" + log_warn " Fix: ensure $(dirname "$_want") is on PATH (re-login if zsh sets it)" + _any_issue=true + elif [ "$_active" != "$_want" ]; then + log_warn "$_tool: PATH lookup → $_active (managed install: $_want)" + log_warn " An unmanaged binary at $_active is shadowing your managed install." + log_warn " Inspect ownership: dpkg -S $_active 2>/dev/null || echo 'manual install'" + log_warn " Fix: remove the shadow ($_active) or fix PATH order" + _any_issue=true + fi + done + + export PATH="$_orig_path" + + if ! $_any_checked; then + log_info "No managed binaries to verify" + elif ! $_any_issue; then + log_ok "All managed binaries resolve to their managed location" + fi +} + # Print current vs latest version comparison (check mode). # Usage: _report_version NAME CURRENT LATEST _report_version() { diff --git a/modules/base.sh b/modules/base.sh index c47cd18..a74b205 100755 --- a/modules/base.sh +++ b/modules/base.sh @@ -410,18 +410,26 @@ _install_jq() { # fzf: install via git clone so shell integration (~/.fzf.zsh) is generated # automatically by the installer. This matches how update.sh manages fzf and # what .zshrc expects (`source ~/.fzf.zsh`). -# A ~/.local/bin/fzf symlink is created so fzf is on PATH without sourcing -# ~/.fzf.zsh (important for install.sh and test.sh which don't source it). +# The ~/.local/bin/fzf symlink is what wins PATH lookup over any older system +# fzf at /usr/local/bin or /usr/bin — and ~/.fzf.zsh's `source <(fzf --zsh)` +# (modern fzf integration style) needs the new fzf to win, otherwise it errors +# with "unknown option: --zsh" on every shell startup. So always (re)create +# the symlink, even when the clone is already present. _install_fzf() { + local skip_clone=false if [ -d ~/.fzf ]; then - log_ok "fzf already installed — skipping" - return + log_ok "fzf already installed — skipping clone" + skip_clone=true + fi + if ! $skip_clone; then + log_step "fzf (git clone)" + log_info "fzf: installing latest → ~/.fzf/ (shell integration via ~/.fzf.zsh, binary symlinked to ~/.local/bin/fzf)" + git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf --quiet + ~/.fzf/install --no-update-rc --key-bindings --completion fi - log_step "fzf (git clone)" - log_info "fzf: installing latest → ~/.fzf/ (shell integration via ~/.fzf.zsh, binary symlinked to ~/.local/bin/fzf)" - git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf --quiet - ~/.fzf/install --no-update-rc --key-bindings --completion mkdir -p ~/.local/bin - ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf - log_ok "fzf installed → ~/.fzf (symlinked to ~/.local/bin/fzf)" + if [ ! -L ~/.local/bin/fzf ] || [ "$(readlink ~/.local/bin/fzf)" != "$HOME/.fzf/bin/fzf" ]; then + ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf + log_ok "fzf: symlinked ~/.local/bin/fzf → ~/.fzf/bin/fzf" + fi } diff --git a/test.sh b/test.sh index 34109ad..acf9e91 100644 --- a/test.sh +++ b/test.sh @@ -135,6 +135,23 @@ if [ -f ~/.fzf.zsh ]; then check_run "~/.fzf.zsh sources without error" zsh -c "source ~/.fzf.zsh" fi +# Catch the magog regression: ~/.fzf cloned but ~/.local/bin/fzf symlink +# missing → an older /usr/local/bin/fzf wins PATH and `fzf --zsh` errors on +# every shell startup. Strict: managed binary must resolve to ~/.local/bin/fzf. +if [ -d ~/.fzf ]; then + if [ -L ~/.local/bin/fzf ] && [ "$(readlink ~/.local/bin/fzf)" = "$HOME/.fzf/bin/fzf" ]; then + _ok "~/.local/bin/fzf symlinked → ~/.fzf/bin/fzf" + else + _fail "~/.local/bin/fzf missing or wrong target — older /usr/local/bin/fzf will shadow modern ~/.fzf/bin/fzf" + fi + _resolved_fzf=$(command -v fzf 2>/dev/null || echo "(none)") + if [ "$_resolved_fzf" = "$HOME/.local/bin/fzf" ] || [ "$_resolved_fzf" = "$HOME/.fzf/bin/fzf" ]; then + _ok "fzf on PATH resolves to managed install ($_resolved_fzf)" + else + _fail "fzf on PATH resolves to $_resolved_fzf — unmanaged binary is shadowing the managed install" + fi +fi + # ── 5. fzf functional ───────────────────────────────────────────────────────── _hdr "fzf functional" diff --git a/update.sh b/update.sh index 6fa98fc..859626b 100755 --- a/update.sh +++ b/update.sh @@ -554,6 +554,20 @@ fi # ── PATH shadow check (always runs — read-only) ─────────────────────────────── _check_path_shadows +# ── Managed-binary resolution check (always runs — read-only) ───────────────── +# Complements _check_path_shadows: that function targets /usr/local/bin tools +# (nvim, xcape); this one targets ~/.local/bin tools and detects the inverse +# failure mode where a stale unmanaged binary at /usr/local/bin or /usr/bin +# wins because the managed ~/.local/bin/X is missing (e.g. broken symlink). +verify_managed_binaries \ + fzf "$HOME/.local/bin/fzf" \ + rg "$HOME/.local/bin/rg" \ + fd "$HOME/.local/bin/fd" \ + jq "$HOME/.local/bin/jq" \ + zoxide "$HOME/.local/bin/zoxide" \ + delta "$HOME/.local/bin/delta" \ + eza "$HOME/.local/bin/eza" + echo "" if $CHECK_ONLY; then log_ok "Check complete" From 76eb89a5b3b84758efd2e7320f3e4c1676286833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20P=C4=99dzim=C4=85=C5=BC?= Date: Sun, 3 May 2026 14:15:45 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20simplify=20v1.4.1=20changes=20?= =?UTF-8?q?=E2=80=94=20drop=20scaffolding,=20DRY=20tool=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _install_fzf: drop skip_clone bool and conditional readlink check — ln -sf is already idempotent over both missing and stale targets. - verify_managed_binaries: tool list moved into the function (single source of truth); replace _any_checked/_any_issue flag pair + PATH save/restore with a single inline PATH= prefix on `command -v`; collapse multi-line warnings to one. ~50 lines → ~15. - install.sh / update.sh: callers become single-line invocations. - test.sh: regression guard now a single conditional fail (success case is silent — the existing fzf-section checks already cover it). - CHANGELOG: trim to essentials. --- CHANGELOG.md | 30 +++++++++---------------- install.sh | 12 +--------- lib/utils.sh | 60 ++++++++++++++----------------------------------- modules/base.sh | 29 +++++++++--------------- test.sh | 20 +++++------------ update.sh | 16 +++---------- 6 files changed, 47 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc4f4a..5a768c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,28 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.4.1] - 2026-05-03 ### Fixed -- `modules/base.sh` `_install_fzf`: short-circuited before creating - `~/.local/bin/fzf` whenever `~/.fzf` was already present from a previous - install. With the symlink missing, any older unmanaged `/usr/local/bin/fzf` - (e.g. left over from a prior manual install) won PATH lookup. Modern - `~/.fzf.zsh` shell integration emits `source <(fzf --zsh)`, which the older - binary rejects with `unknown option: --zsh` on every shell startup. The - installer is now idempotent: it always reconciles `~/.local/bin/fzf` → - `~/.fzf/bin/fzf`, even when the clone already exists. +- `_install_fzf`: skipped symlink reconciliation when `~/.fzf` already + existed, so re-runs over partial state left `~/.local/bin/fzf` missing + and an older system fzf could win PATH lookup — breaking modern + `~/.fzf.zsh` integration with "unknown option: --zsh" on every shell + startup. Now always reconciles the symlink. ### Added -- `lib/utils.sh` `verify_managed_binaries`: post-install / post-update check - that simulates the user's interactive zsh PATH and warns when a managed - `~/.local/bin/` binary is shadowed by an unmanaged binary elsewhere on - PATH (or is missing entirely). Covers `fzf`, `rg`, `fd`, `jq`, `zoxide`, - `delta`, `eza`. Quiet on a clean system. -- `install.sh`: invokes `verify_managed_binaries` after install completes. -- `update.sh`: invokes `verify_managed_binaries` alongside the existing - `_check_path_shadows` (which only covered `/usr/local/bin/{nvim,xcape}` — - the inverse failure mode for `~/.local/bin` tools was previously uncaught). -- `test.sh`: strict resolution check for `fzf` — verifies `~/.local/bin/fzf` - symlink target and that `command -v fzf` resolves to a managed location. - Catches the regression class above before it ships. +- `verify_managed_binaries` (`lib/utils.sh`): read-only check, run at end + of `install.sh` and `update.sh`, warns when an unmanaged binary shadows + a managed `~/.local/bin/`. Complements `_check_path_shadows` + which only covers `/usr/local/bin/{nvim,xcape}`. +- `test.sh`: regression guard verifying `~/.local/bin/fzf` symlink target. ## [1.4.0] - 2026-04-13 diff --git a/install.sh b/install.sh index d39c777..f2283ed 100755 --- a/install.sh +++ b/install.sh @@ -115,17 +115,7 @@ case "$PROFILE" in *) die "Unknown profile: '$PROFILE'. Valid options: minimal | workstation | docker" ;; esac -# Catch broken installations where an unmanaged system binary shadows a managed -# install (e.g. a stale /usr/local/bin/fzf masking ~/.local/bin/fzf). Quiet on -# clean systems; loud when something is wrong. -verify_managed_binaries \ - fzf "$HOME/.local/bin/fzf" \ - rg "$HOME/.local/bin/rg" \ - fd "$HOME/.local/bin/fd" \ - jq "$HOME/.local/bin/jq" \ - zoxide "$HOME/.local/bin/zoxide" \ - delta "$HOME/.local/bin/delta" \ - eza "$HOME/.local/bin/eza" +verify_managed_binaries echo "" log_ok "Setup complete!" diff --git a/lib/utils.sh b/lib/utils.sh index 9b81255..273d2f9 100755 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -281,54 +281,28 @@ _verify_dest() { fi } -# Verify managed binaries resolve to their expected install path. -# Catches PATH shadowing by unmanaged binaries (e.g. an old /usr/local/bin/fzf -# left over from a previous install masking ~/.local/bin/fzf when the symlink -# is missing). Silent on a clean system; warns when a shadow is detected. +# Warn when a managed ~/.local/bin/ is shadowed by an unmanaged binary +# elsewhere on PATH. Read-only; silent on clean systems. Only flags tools that +# actually exist at ~/.local/bin/ (skipped silently otherwise — e.g. when +# delta/eza came from apt at /usr/bin instead). # -# We approximate the user's interactive zsh PATH (~/.zshrc puts ~/.local/bin -# ahead of system dirs) so the check predicts what shell startup will resolve, -# not what install-time bash sees. -# -# Usage: verify_managed_binaries TOOL1 PATH1 TOOL2 PATH2 ... +# PATH is prefixed with ~/.local/bin so the answer matches what the user's +# interactive zsh resolves (zshrc prepends it), not install-time bash. verify_managed_binaries() { log_step "Verify managed binaries on PATH" - - local _orig_path="$PATH" - # Mirror .zshrc line 7 ordering. Harmless if already in $PATH. - export PATH="$HOME/go/bin:$HOME/.local/bin:$HOME/bin:/usr/local/bin:$PATH" - - local _any_issue=false _any_checked=false - while [ "$#" -ge 2 ]; do - local _tool="$1" _want="$2" - shift 2 - # Tool not installed at managed location → not our concern (skip silently). - # Use -e so a valid symlink to a missing target is still flagged below. - [ -e "$_want" ] || [ -L "$_want" ] || continue - _any_checked=true - - local _active - _active=$(command -v "$_tool" 2>/dev/null || true) - if [ -z "$_active" ]; then - log_warn "$_tool: installed at $_want but not on PATH" - log_warn " Fix: ensure $(dirname "$_want") is on PATH (re-login if zsh sets it)" - _any_issue=true - elif [ "$_active" != "$_want" ]; then - log_warn "$_tool: PATH lookup → $_active (managed install: $_want)" - log_warn " An unmanaged binary at $_active is shadowing your managed install." - log_warn " Inspect ownership: dpkg -S $_active 2>/dev/null || echo 'manual install'" - log_warn " Fix: remove the shadow ($_active) or fix PATH order" - _any_issue=true + local issues=0 tool want resolved + for tool in fzf rg fd jq zoxide delta eza; do + want="$HOME/.local/bin/$tool" + [ -L "$want" ] || [ -e "$want" ] || continue + resolved=$(PATH="$HOME/.local/bin:$PATH" command -v "$tool" 2>/dev/null) || resolved="" + if [ "$resolved" != "$want" ]; then + log_warn "$tool: PATH → '${resolved:-}', expected $want" + log_warn " Stale unmanaged binary is shadowing your managed install." + log_warn " Remove it after confirming with 'dpkg -S $resolved'." + issues=$((issues+1)) fi done - - export PATH="$_orig_path" - - if ! $_any_checked; then - log_info "No managed binaries to verify" - elif ! $_any_issue; then - log_ok "All managed binaries resolve to their managed location" - fi + [ "$issues" -eq 0 ] && log_ok "All managed binaries resolve correctly" } # Print current vs latest version comparison (check mode). diff --git a/modules/base.sh b/modules/base.sh index a74b205..e1d083c 100755 --- a/modules/base.sh +++ b/modules/base.sh @@ -408,28 +408,21 @@ _install_jq() { } # fzf: install via git clone so shell integration (~/.fzf.zsh) is generated -# automatically by the installer. This matches how update.sh manages fzf and -# what .zshrc expects (`source ~/.fzf.zsh`). -# The ~/.local/bin/fzf symlink is what wins PATH lookup over any older system -# fzf at /usr/local/bin or /usr/bin — and ~/.fzf.zsh's `source <(fzf --zsh)` -# (modern fzf integration style) needs the new fzf to win, otherwise it errors -# with "unknown option: --zsh" on every shell startup. So always (re)create -# the symlink, even when the clone is already present. +# automatically by the installer. The ~/.local/bin/fzf symlink is what wins +# PATH lookup over any older system fzf at /usr/local/bin or /usr/bin — and +# ~/.fzf.zsh's `source <(fzf --zsh)` (modern fzf integration style) needs the +# new fzf to win, otherwise shell startup errors with "unknown option: --zsh". +# Always reconcile the symlink, even when the clone already exists, so re-runs +# over partial state self-heal. _install_fzf() { - local skip_clone=false - if [ -d ~/.fzf ]; then - log_ok "fzf already installed — skipping clone" - skip_clone=true - fi - if ! $skip_clone; then + if [ ! -d ~/.fzf ]; then log_step "fzf (git clone)" - log_info "fzf: installing latest → ~/.fzf/ (shell integration via ~/.fzf.zsh, binary symlinked to ~/.local/bin/fzf)" + log_info "fzf: installing latest → ~/.fzf/" git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf --quiet ~/.fzf/install --no-update-rc --key-bindings --completion + else + log_ok "fzf already installed — skipping clone" fi mkdir -p ~/.local/bin - if [ ! -L ~/.local/bin/fzf ] || [ "$(readlink ~/.local/bin/fzf)" != "$HOME/.fzf/bin/fzf" ]; then - ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf - log_ok "fzf: symlinked ~/.local/bin/fzf → ~/.fzf/bin/fzf" - fi + ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf } diff --git a/test.sh b/test.sh index acf9e91..9eab42c 100644 --- a/test.sh +++ b/test.sh @@ -135,21 +135,11 @@ if [ -f ~/.fzf.zsh ]; then check_run "~/.fzf.zsh sources without error" zsh -c "source ~/.fzf.zsh" fi -# Catch the magog regression: ~/.fzf cloned but ~/.local/bin/fzf symlink -# missing → an older /usr/local/bin/fzf wins PATH and `fzf --zsh` errors on -# every shell startup. Strict: managed binary must resolve to ~/.local/bin/fzf. -if [ -d ~/.fzf ]; then - if [ -L ~/.local/bin/fzf ] && [ "$(readlink ~/.local/bin/fzf)" = "$HOME/.fzf/bin/fzf" ]; then - _ok "~/.local/bin/fzf symlinked → ~/.fzf/bin/fzf" - else - _fail "~/.local/bin/fzf missing or wrong target — older /usr/local/bin/fzf will shadow modern ~/.fzf/bin/fzf" - fi - _resolved_fzf=$(command -v fzf 2>/dev/null || echo "(none)") - if [ "$_resolved_fzf" = "$HOME/.local/bin/fzf" ] || [ "$_resolved_fzf" = "$HOME/.fzf/bin/fzf" ]; then - _ok "fzf on PATH resolves to managed install ($_resolved_fzf)" - else - _fail "fzf on PATH resolves to $_resolved_fzf — unmanaged binary is shadowing the managed install" - fi +# Regression guard: ~/.fzf cloned without the ~/.local/bin/fzf symlink lets an +# older system fzf win PATH; modern ~/.fzf.zsh then fails with "unknown option: +# --zsh" on every shell startup. +if [ -d ~/.fzf ] && [ "$(readlink ~/.local/bin/fzf 2>/dev/null)" != "$HOME/.fzf/bin/fzf" ]; then + _fail "~/.local/bin/fzf missing or wrong target (got: $(readlink ~/.local/bin/fzf 2>/dev/null || echo none))" fi # ── 5. fzf functional ───────────────────────────────────────────────────────── diff --git a/update.sh b/update.sh index 859626b..e2fa5b3 100755 --- a/update.sh +++ b/update.sh @@ -554,19 +554,9 @@ fi # ── PATH shadow check (always runs — read-only) ─────────────────────────────── _check_path_shadows -# ── Managed-binary resolution check (always runs — read-only) ───────────────── -# Complements _check_path_shadows: that function targets /usr/local/bin tools -# (nvim, xcape); this one targets ~/.local/bin tools and detects the inverse -# failure mode where a stale unmanaged binary at /usr/local/bin or /usr/bin -# wins because the managed ~/.local/bin/X is missing (e.g. broken symlink). -verify_managed_binaries \ - fzf "$HOME/.local/bin/fzf" \ - rg "$HOME/.local/bin/rg" \ - fd "$HOME/.local/bin/fd" \ - jq "$HOME/.local/bin/jq" \ - zoxide "$HOME/.local/bin/zoxide" \ - delta "$HOME/.local/bin/delta" \ - eza "$HOME/.local/bin/eza" +# Complements _check_path_shadows above (which targets /usr/local/bin tools) +# by warning when a stale system binary shadows a ~/.local/bin managed tool. +verify_managed_binaries echo "" if $CHECK_ONLY; then From 22c6b0ba7b6d10752eb56bee80b9a072507e9932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20P=C4=99dzim=C4=85=C5=BC?= Date: Sun, 3 May 2026 22:41:33 +0200 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20drop=20verify=5Fmanaged=5Fbinar?= =?UTF-8?q?ies=20=E2=80=94=20wrong=20abstraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-test on real install scenarios revealed false-positive flooding: the function assumed every tool in its hardcoded list lives at ~/.local/bin/, which is only true in nosudo mode. In sudo mode (the common case), rg/fd/jq/zoxide/delta/eza are apt-installed at /usr/bin and the function would warn "an unmanaged binary will run instead" — falsely, since the apt binary IS the managed install. The actual bug class is fzf-specific: modern ~/.fzf.zsh integration (`source <(fzf --zsh)`) requires PATH to resolve to fzf >= 0.48. Other tools tolerate stale shadows without breaking shell startup. Reverting to minimal correct design: - _install_fzf already self-heals on every install (idempotent symlink). - update.sh fzf section now also self-heals — fixes existing broken installs from older dotfiles versions on next ./update.sh run. - test.sh regression guard remains as the CI-time backstop. --- CHANGELOG.md | 10 +++++----- install.sh | 2 -- lib/utils.sh | 24 ------------------------ update.sh | 8 ++++---- 4 files changed, 9 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a768c9..7588e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,13 +15,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and an older system fzf could win PATH lookup — breaking modern `~/.fzf.zsh` integration with "unknown option: --zsh" on every shell startup. Now always reconciles the symlink. +- `update.sh` fzf section: same self-heal — reconciles the symlink after + every update, fixing existing broken installs from older dotfiles + versions without requiring a full re-install. ### Added -- `verify_managed_binaries` (`lib/utils.sh`): read-only check, run at end - of `install.sh` and `update.sh`, warns when an unmanaged binary shadows - a managed `~/.local/bin/`. Complements `_check_path_shadows` - which only covers `/usr/local/bin/{nvim,xcape}`. -- `test.sh`: regression guard verifying `~/.local/bin/fzf` symlink target. +- `test.sh`: regression guard verifying `~/.local/bin/fzf` symlink target — + fails CI if the symlink reconciliation is ever removed from the installer. ## [1.4.0] - 2026-04-13 diff --git a/install.sh b/install.sh index f2283ed..f4ba075 100755 --- a/install.sh +++ b/install.sh @@ -115,8 +115,6 @@ case "$PROFILE" in *) die "Unknown profile: '$PROFILE'. Valid options: minimal | workstation | docker" ;; esac -verify_managed_binaries - echo "" log_ok "Setup complete!" echo "" diff --git a/lib/utils.sh b/lib/utils.sh index 273d2f9..bb09581 100755 --- a/lib/utils.sh +++ b/lib/utils.sh @@ -281,30 +281,6 @@ _verify_dest() { fi } -# Warn when a managed ~/.local/bin/ is shadowed by an unmanaged binary -# elsewhere on PATH. Read-only; silent on clean systems. Only flags tools that -# actually exist at ~/.local/bin/ (skipped silently otherwise — e.g. when -# delta/eza came from apt at /usr/bin instead). -# -# PATH is prefixed with ~/.local/bin so the answer matches what the user's -# interactive zsh resolves (zshrc prepends it), not install-time bash. -verify_managed_binaries() { - log_step "Verify managed binaries on PATH" - local issues=0 tool want resolved - for tool in fzf rg fd jq zoxide delta eza; do - want="$HOME/.local/bin/$tool" - [ -L "$want" ] || [ -e "$want" ] || continue - resolved=$(PATH="$HOME/.local/bin:$PATH" command -v "$tool" 2>/dev/null) || resolved="" - if [ "$resolved" != "$want" ]; then - log_warn "$tool: PATH → '${resolved:-}', expected $want" - log_warn " Stale unmanaged binary is shadowing your managed install." - log_warn " Remove it after confirming with 'dpkg -S $resolved'." - issues=$((issues+1)) - fi - done - [ "$issues" -eq 0 ] && log_ok "All managed binaries resolve correctly" -} - # Print current vs latest version comparison (check mode). # Usage: _report_version NAME CURRENT LATEST _report_version() { diff --git a/update.sh b/update.sh index e2fa5b3..b7f8375 100755 --- a/update.sh +++ b/update.sh @@ -388,6 +388,10 @@ if _should_run fzf; then else log_warn "fzf: git pull failed — skipping" fi + # Self-heal the symlink in case an older dotfiles version (where + # _install_fzf returned early when ~/.fzf existed) left it missing. + mkdir -p ~/.local/bin + ln -sf ~/.fzf/bin/fzf ~/.local/bin/fzf fi elif has fzf; then log_warn "fzf: not managed as a git clone at ~/.fzf — skipping (update manually)" @@ -554,10 +558,6 @@ fi # ── PATH shadow check (always runs — read-only) ─────────────────────────────── _check_path_shadows -# Complements _check_path_shadows above (which targets /usr/local/bin tools) -# by warning when a stale system binary shadows a ~/.local/bin managed tool. -verify_managed_binaries - echo "" if $CHECK_ONLY; then log_ok "Check complete" From 3f3c32131dac6f060a7c8a361aecee343cfcff1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20P=C4=99dzim=C4=85=C5=BC?= Date: Sun, 3 May 2026 23:04:47 +0200 Subject: [PATCH 4/4] docs: document the fzf symlink invariant in CLAUDE.md --- CLAUDE.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8448a24..6ee3940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,10 +277,8 @@ warns and bails rather than creating a link inside it. ## fzf integration fzf is installed via **git clone** to `~/.fzf/` (not apt, not a binary release). -The installer generates `~/.fzf.zsh` which adds `~/.fzf/bin` to PATH and registers -`Ctrl+T`/`Ctrl+R`/`Alt+C` bindings. - -`~/.fzf.zsh` is sourced **explicitly** in `.zshrc`: +The installer generates `~/.fzf.zsh` which contains `source <(fzf --zsh)` (modern +fzf integration, fzf ≥ 0.48) and is sourced **explicitly** in `.zshrc`: ```bash [[ -f ~/.fzf.zsh ]] && source ~/.fzf.zsh ``` @@ -288,6 +286,14 @@ The oh-my-zsh `fzf` plugin was removed — this explicit source line is the **on thing that activates fzf shell integration. Do not remove it. Do not re-add the oh-my-zsh `fzf` plugin without removing this line first. +**Load-bearing invariant**: `~/.local/bin/fzf` must symlink to `~/.fzf/bin/fzf`. +Because `~/.fzf.zsh` calls `fzf --zsh` at shell startup, an older system fzf at +`/usr/local/bin/fzf` or `/usr/bin/fzf` shadowing the managed install will print +`unknown option: --zsh` on every login. `_install_fzf` in `modules/base.sh` and +the fzf section of `update.sh` both **always reconcile this symlink** (idempotent +`ln -sf` after the existence check), even when the clone is already present — +so re-runs over partial state self-heal. `test.sh` carries a regression guard. + --- ## Global flags