Skip to content

Commit ba10781

Browse files
committed
feat(scripts): surface duplicate-install cleanup and upgrade discoverability
guide.sh now renders per-install version + active (PATH default) + preferred (kept) markers for duplicates and offers an inline 'c' action to remove the non-preferred ones. Adds 'make reconcile-all' (confirm-each) and 'reconcile-all-dry-run' (plan only). The audit readiness line and guide summary hint at 'make reconcile-all' (when duplicates exist) and 'make upgrade-managed'. auto_update.sh warns that native manager upgrades bypass cli-audit pins and project lockfiles before running (skipped on dry-run or FORCE=1). Signed-off-by: Sebastian Mendel <github@sebastianmendel.de>
1 parent 123d267 commit ba10781

6 files changed

Lines changed: 165 additions & 1 deletion

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export
1717
install install-dev install-core install-python install-node install-go \
1818
install-aws install-kubectl install-terraform install-ansible install-docker \
1919
install-brew install-rust install-uv install-% upgrade-% uninstall-% reconcile-% \
20+
reconcile-all reconcile-all-dry-run \
2021
build build-dist build-wheel check-dist publish-test publish-prod \
2122
clean clean-build clean-test clean-pyc clean-all \
2223
scripts-perms audit-auto detect-managers upgrade-managed upgrade-dry-run \

Makefile.d/user.mk

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,12 @@ reconcile-%: scripts-perms ## Reconcile tool installation (e.g., make reconcile-
224224
echo "Error: No installer found for '$*'" >&2; exit 1; \
225225
fi
226226

227+
reconcile-all: scripts-perms ## Remove duplicate installs across ALL tools (confirm each; keeps preferred)
228+
@$(PYTHON) audit.py --reconcile --all --apply
229+
230+
reconcile-all-dry-run: scripts-perms ## Preview duplicate-install cleanup across ALL tools (removes nothing)
231+
@$(PYTHON) audit.py --reconcile --all
232+
227233
# ----------------------------------------------------------------------------
228234
# SYSTEM MANAGEMENT
229235
# ----------------------------------------------------------------------------

cli_audit/render.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,16 @@ def _effective(t: dict[str, Any]) -> str:
332332
f"\nReadiness{offline_tag}: {', '.join(parts)}",
333333
file=sys.stderr,
334334
)
335+
336+
# Discoverability hints (only when relevant)
337+
if conflicts > 0:
338+
print(
339+
f" → {conflicts} tool(s) with duplicate installs: "
340+
"'make reconcile-all' (keeps preferred, removes the rest)",
341+
file=sys.stderr,
342+
)
343+
if outdated > 0:
344+
print(
345+
" → upgrade the package managers themselves: 'make upgrade-managed'",
346+
file=sys.stderr,
347+
)

scripts/auto_update.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,25 @@ run_all_updates() {
843843
# Determine target scope
844844
local target_scope="${SCOPE:-$(determine_default_scope)}"
845845

846+
# Pin/lock override warning: this runs each package manager's own upgrade,
847+
# which moves tools past the versions cli-audit pins (pins.json) and any
848+
# project lockfiles. Skip the prompt in dry-run or when FORCE=1 (CI).
849+
if [ "$DRY_RUN" != "1" ] && [ "${FORCE:-0}" != "1" ]; then
850+
printf "[auto-update] ⚠️ This runs each package manager's own upgrade. It moves tools\n" >&2
851+
printf " PAST the versions pinned in cli-audit (pins.json) and any project\n" >&2
852+
printf " lockfiles — pins are NOT enforced here.\n" >&2
853+
local ans=""
854+
if [ -t 0 ]; then
855+
read -r -p "[auto-update] Continue? [y/N] " ans || true
856+
elif [ -r /dev/tty ]; then
857+
read -r -p "[auto-update] Continue? [y/N] " ans </dev/tty || true
858+
fi
859+
if [ "$ans" != "y" ] && [ "$ans" != "Y" ]; then
860+
log "Aborted (no changes made)."
861+
return 0
862+
fi
863+
fi
864+
846865
log "Starting auto-update for scope: $target_scope"
847866
echo ""
848867

scripts/guide.sh

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ CLI="${PYTHON:-python3}"
1616
# Ignore pins: IGNORE_PINS=1 to show all tools regardless of pin status
1717
IGNORE_PINS="${IGNORE_PINS:-0}"
1818

19+
# Duplicate-install count for the tool most recently passed to
20+
# check_multi_installs(); consumed by process_tool to offer inline cleanup.
21+
LAST_MULTI_COUNT=0
22+
# Space-separated tool names seen with duplicate installs this run (for the
23+
# summary hint); de-duplicated when counted.
24+
GUIDE_DUP_LIST=""
25+
1926
# Summary counters
2027
SUMMARY_UPDATED=0
2128
SUMMARY_SKIPPED=0
@@ -33,6 +40,14 @@ print_summary() {
3340
printf " Failed: %d\n" "$SUMMARY_FAILED"
3441
echo
3542
echo "Re-run: make audit"
43+
local dup_count=0
44+
if [ -n "${GUIDE_DUP_LIST// /}" ]; then
45+
dup_count="$(printf '%s\n' $GUIDE_DUP_LIST | sort -u | grep -c . || true)"
46+
fi
47+
if [ "$dup_count" -gt 0 ]; then
48+
printf " → %d tool(s) with duplicate installs: 'make reconcile-all'\n" "$dup_count"
49+
fi
50+
echo " → upgrade the package managers themselves: 'make upgrade-managed'"
3651
}
3752

3853
# Category filter: CATEGORY=python,go or --category=python
@@ -221,11 +236,50 @@ check_multi_installs() {
221236
local binary_name
222237
binary_name="$(catalog_get_property "$catalog_tool" binary_name)"
223238
binary_name="${binary_name:-$catalog_tool}"
239+
# Cheap shell pre-check: only pay for the richer reconcile query when there
240+
# actually is more than one install (the common case is a single install).
224241
local all_installs
225242
all_installs="$(detect_all_installations "$catalog_tool" "$binary_name" 2>/dev/null || true)"
226243
local install_count
227244
install_count="$(echo "$all_installs" | grep -c . || true)"
228-
if [ "$install_count" -gt 1 ]; then
245+
LAST_MULTI_COUNT="$install_count"
246+
[ "$install_count" -le 1 ] && return 0
247+
GUIDE_DUP_LIST="$GUIDE_DUP_LIST $catalog_tool"
248+
249+
# Duplicates exist — render version + active + preferred markers from the
250+
# reconcile plan (the single source of truth). Fall back to the basic listing
251+
# if reconcile produces nothing.
252+
local recon_json rendered
253+
recon_json="$(cd "$ROOT" && CLI_AUDIT_JSON=1 "$CLI" audit.py --reconcile "$catalog_tool" 2>/dev/null || true)"
254+
rendered="$(RECON_JSON="$recon_json" "$CLI" - <<'PY'
255+
import os, json
256+
try:
257+
doc = json.loads(os.environ.get("RECON_JSON", "").strip())
258+
plan = (doc.get("results") or [None])[0]
259+
installs = plan.get("installations") or []
260+
except Exception:
261+
raise SystemExit(0)
262+
if len(installs) < 2:
263+
raise SystemExit(0)
264+
active_path = (plan.get("active") or {}).get("path")
265+
pref_path = (plan.get("preferred") or {}).get("path")
266+
lines = [f" ⚠️ Multiple installations detected ({len(installs)}):"]
267+
for i in installs:
268+
marks = []
269+
if i.get("active"):
270+
marks.append("→ active")
271+
if i.get("preferred"):
272+
marks.append("✓ keep")
273+
suffix = (" " + " ".join(marks)) if marks else ""
274+
lines.append(f" • {i.get('version') or '?'} {i.get('method')} {i.get('path')}{suffix}")
275+
if active_path and pref_path and active_path != pref_path:
276+
lines.append(f" note: cleanup would change which {plan.get('tool')} runs (active → preferred).")
277+
print("\n".join(lines))
278+
PY
279+
)"
280+
if [ -n "$rendered" ]; then
281+
printf '%s\n' "$rendered"
282+
else
229283
printf " ⚠️ Multiple installations detected (%d):\n" "$install_count"
230284
echo "$all_installs" | while IFS=: read -r inst_method inst_path; do
231285
printf " • %s: %s\n" "$inst_method" "$inst_path"
@@ -446,6 +500,9 @@ process_tool() {
446500
printf " p = Pin to %s (don't ask for upgrades)\n" "$installed"
447501
fi
448502
printf " r = Remove/uninstall this tool\n"
503+
if [ "${LAST_MULTI_COUNT:-0}" -gt 1 ]; then
504+
printf " c = Clean up duplicates (keep preferred, remove the rest)\n"
505+
fi
449506
if [ -n "$is_multi_version" ]; then
450507
printf " P = Skip ALL outdated %s cycles\n" "$catalog_tool"
451508
fi
@@ -470,6 +527,9 @@ process_tool() {
470527
else
471528
prompt_text="Upgrade? [Y/a/n/s/p/r] "
472529
fi
530+
if [ "${LAST_MULTI_COUNT:-0}" -gt 1 ]; then
531+
prompt_text="${prompt_text%] }/c] "
532+
fi
473533
else
474534
if [ -n "$is_multi_version" ]; then
475535
prompt_text="Install? [y/a/N/s/p/P] "
@@ -706,6 +766,32 @@ process_tool() {
706766
printf " Tool is not installed, nothing to remove\n"
707767
fi
708768
;;
769+
[Cc])
770+
# Clean up duplicate installations: keep the preferred, remove the rest.
771+
# The plan (keep/remove, active marker) was already shown by
772+
# check_multi_installs above. Confirm the destructive step, then apply.
773+
if [ "${LAST_MULTI_COUNT:-0}" -gt 1 ]; then
774+
local ans_c=""
775+
if [ -t 0 ]; then
776+
read -r -p " Remove the non-preferred duplicate(s)? [y/N] " ans_c || true
777+
elif [ -r /dev/tty ]; then
778+
read -r -p " Remove the non-preferred duplicate(s)? [y/N] " ans_c </dev/tty || true
779+
fi
780+
if [ "$ans_c" = "y" ] || [ "$ans_c" = "Y" ]; then
781+
if (cd "$ROOT" && "$CLI" audit.py --reconcile "$catalog_tool" --apply --yes); then
782+
printf " ✓ Duplicates removed (kept preferred install)\n"
783+
else
784+
printf " ⚠️ Cleanup did not complete (tool may be on the protect list)\n"
785+
fi
786+
CLI_AUDIT_JSON=1 CLI_AUDIT_COLLECT=1 CLI_AUDIT_MERGE=1 "$CLI" audit.py "$tool" >/dev/null 2>&1 || true
787+
reload_audit_json
788+
else
789+
printf " Skipped cleanup\n"
790+
fi
791+
else
792+
printf " No duplicate installations to clean up\n"
793+
fi
794+
;;
709795
[P])
710796
# Skip ALL versions of this runtime (only for multi-version tools)
711797
if [ -n "$is_multi_version" ]; then

tests/test_reconcile_dryrun.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
# Guards the safety-critical reconcile invariants at the shell/CLI boundary:
3+
# - the dry-run make target must NEVER pass --apply (no removals)
4+
# - the real target must pass --apply
5+
# - the CLI requires an explicit tool or --all
6+
set -uo pipefail
7+
8+
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9+
ROOT="$(cd "$DIR/.." && pwd)"
10+
PYTHON="${PYTHON:-python3}"
11+
cd "$ROOT"
12+
13+
pass=0
14+
fail=0
15+
check() { # desc, condition-exit-code
16+
if [ "$2" -eq 0 ]; then echo " PASS: $1"; pass=$((pass + 1));
17+
else echo " FAIL: $1"; fail=$((fail + 1)); fi
18+
}
19+
20+
echo "=== Test: reconcile-all-dry-run never applies ==="
21+
dryrun_recipe="$(grep -A1 '^reconcile-all-dry-run:' Makefile.d/user.mk | tail -1)"
22+
echo "$dryrun_recipe" | grep -q -- '--reconcile'; check "dry-run uses --reconcile" $?
23+
echo "$dryrun_recipe" | grep -q -- '--all'; check "dry-run uses --all" $?
24+
if echo "$dryrun_recipe" | grep -q -- '--apply'; then check "dry-run must NOT use --apply" 1; else check "dry-run must NOT use --apply" 0; fi
25+
26+
echo "=== Test: reconcile-all applies ==="
27+
apply_recipe="$(grep -A1 '^reconcile-all:' Makefile.d/user.mk | tail -1)"
28+
echo "$apply_recipe" | grep -q -- '--apply'; check "reconcile-all uses --apply" $?
29+
30+
echo "=== Test: CLI contract ==="
31+
"$PYTHON" audit.py --reconcile >/dev/null 2>&1
32+
check "reconcile without tool/--all exits non-zero" "$([ $? -ne 0 ] && echo 0 || echo 1)"
33+
34+
"$PYTHON" audit.py --help 2>&1 | grep -q -- '--reconcile'
35+
check "--reconcile listed in --help" $?
36+
37+
echo ""
38+
echo "Results: $pass passed, $fail failed"
39+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)