From cde10f953e367e014ec0d6e9b009a4a7e5e6c390 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Fri, 17 Jul 2026 08:08:45 +0200 Subject: [PATCH] feat: tool@cycle make targets (make uninstall-node@24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make install-/upgrade-/uninstall-@ now dispatches through scripts/cycle_action.sh, which maps the cycle to the dedicated installer's version env var (NODE_VERSION, RUBY_VERSION, GO_VERSION, UV_PYTHON_SPEC) — no more remembering per-tool env var names. Signed-off-by: Sebastian Mendel --- AGENTS.md | 2 + Makefile.d/user.mk | 16 ++++-- scripts/cycle_action.sh | 44 +++++++++++++++++ tests/test_cycle_targets.py | 97 +++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 5 deletions(-) create mode 100755 scripts/cycle_action.sh create mode 100644 tests/test_cycle_targets.py diff --git a/AGENTS.md b/AGENTS.md index dc1ba1a..0852dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,8 @@ This repo **is** a CLI tool manager, so the word "upgrade" is overloaded: | `make upgrade-` | Upgrade any tool (e.g., `make upgrade-ripgrep`) | | `make uninstall-` | Uninstall any tool (e.g., `make uninstall-jq`) | | `make reconcile-` | Reconcile install method (e.g., `make reconcile-node`) | +| `make uninstall-@` | Remove one runtime version cycle (e.g., `make uninstall-node@24`, `uninstall-ruby@3.3`) | +| `make install-@` / `upgrade-@` | Install/upgrade a specific cycle (node, ruby, go, python) | | `make upgrade` | Interactive upgrade guide | | `make cleanup` | Interactive tool removal | | `make upgrade-managed` | Upgrade all package managers | diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index 667a161..3b62e5e 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -88,8 +88,10 @@ reset-pins: scripts-perms ## Remove all version pins from all tools guide: upgrade ## Alias for upgrade (deprecated) -upgrade-%: scripts-perms ## Upgrade tool (e.g., make upgrade-python) - @if [ -f "./scripts/install_$*.sh" ]; then \ +upgrade-%: scripts-perms ## Upgrade tool or cycle (e.g., make upgrade-python, upgrade-node@24) + @spec="$*"; if [ "$${spec#*@}" != "$$spec" ]; then \ + ./scripts/cycle_action.sh "$$spec" update; \ + elif [ -f "./scripts/install_$*.sh" ]; then \ ./scripts/install_$*.sh update; \ elif [ -f "./catalog/$*.json" ]; then \ ./scripts/install_tool.sh "$*" update; \ @@ -188,7 +190,9 @@ install-uv: scripts-perms ## Install uv package manager # Generic fallback: install any cataloged tool (e.g., make install-jq) install-%: scripts-perms - @if [ -f "./scripts/install_$*.sh" ]; then \ + @spec="$*"; if [ "$${spec#*@}" != "$$spec" ]; then \ + ./scripts/cycle_action.sh "$$spec" install; \ + elif [ -f "./scripts/install_$*.sh" ]; then \ ./scripts/install_$*.sh; \ elif [ -f "./catalog/$*.json" ]; then \ ./scripts/install_tool.sh "$*" install; \ @@ -200,8 +204,10 @@ install-%: scripts-perms # UNINSTALL / RECONCILE # ---------------------------------------------------------------------------- -uninstall-%: scripts-perms ## Uninstall tool (e.g., make uninstall-python) - @if [ -f "./scripts/install_$*.sh" ]; then \ +uninstall-%: scripts-perms ## Uninstall tool or cycle (e.g., make uninstall-python, uninstall-node@24) + @spec="$*"; if [ "$${spec#*@}" != "$$spec" ]; then \ + ./scripts/cycle_action.sh "$$spec" uninstall; \ + elif [ -f "./scripts/install_$*.sh" ]; then \ ./scripts/install_$*.sh uninstall; \ elif [ -f "./catalog/$*.json" ]; then \ ./scripts/install_tool.sh "$*" uninstall; \ diff --git a/scripts/cycle_action.sh b/scripts/cycle_action.sh new file mode 100755 index 0000000..e9d142b --- /dev/null +++ b/scripts/cycle_action.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Dispatch tool@cycle make targets (e.g. make uninstall-node@24) to the +# dedicated multi-version installer with the tool's version env var set. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Test hook: point at stubbed installers +INSTALLER_DIR="${INSTALLER_DIR:-$DIR}" + +SPEC="${1:-}" +ACTION="${2:-install}" + +if [ -z "$SPEC" ] || [ "${SPEC#*@}" = "$SPEC" ]; then + echo "Usage: $0 TOOL@CYCLE [install|update|uninstall]" >&2 + echo "Example: $0 node@24 uninstall" >&2 + exit 2 +fi + +TOOL="${SPEC%%@*}" +CYCLE="${SPEC#*@}" + +if [ -z "$TOOL" ] || [ -z "$CYCLE" ]; then + echo "Error: Invalid spec '$SPEC' (expected TOOL@CYCLE, e.g. node@24)" >&2 + exit 2 +fi + +case "$TOOL" in + node) + NODE_VERSION="$CYCLE" exec "$INSTALLER_DIR/install_node.sh" "$ACTION" + ;; + ruby) + RUBY_VERSION="$CYCLE" exec "$INSTALLER_DIR/install_ruby.sh" "$ACTION" + ;; + go) + GO_VERSION="$CYCLE" exec "$INSTALLER_DIR/install_go.sh" "$ACTION" + ;; + python) + UV_PYTHON_SPEC="$CYCLE" exec "$INSTALLER_DIR/install_python.sh" "$ACTION" + ;; + *) + echo "Error: '$TOOL' has no version-cycle support (supported: node, ruby, go, python)" >&2 + exit 1 + ;; +esac diff --git a/tests/test_cycle_targets.py b/tests/test_cycle_targets.py new file mode 100644 index 0000000..72119bf --- /dev/null +++ b/tests/test_cycle_targets.py @@ -0,0 +1,97 @@ +"""Tests for tool@cycle make targets (make uninstall-node@24 etc.). + +cycle_action.sh maps TOOL@CYCLE to the dedicated installer with the tool's +version env var set; the install-%/upgrade-%/uninstall-% pattern targets +branch to it whenever the stem contains '@'. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +skip_on_windows = pytest.mark.skipif( + sys.platform == "win32", reason="Shell script tests require POSIX shell" +) + +ROOT = Path(__file__).parent.parent +SCRIPT = ROOT / "scripts" / "cycle_action.sh" + +STUB = ( + "#!/usr/bin/env bash\n" + 'echo "{name} action=$1' + ' NODE_VERSION=${{NODE_VERSION:-}} RUBY_VERSION=${{RUBY_VERSION:-}}' + ' GO_VERSION=${{GO_VERSION:-}} UV_PYTHON_SPEC=${{UV_PYTHON_SPEC:-}}"\n' +) + + +@skip_on_windows +class TestCycleAction: + def _run(self, tmp_path: Path, spec: str, action: str) -> subprocess.CompletedProcess: + stub_dir = tmp_path / "installers" + stub_dir.mkdir(exist_ok=True) + for name in ("install_node.sh", "install_ruby.sh", "install_go.sh", "install_python.sh"): + stub = stub_dir / name + stub.write_text(STUB.format(name=name)) + stub.chmod(0o755) + return subprocess.run( + ["bash", str(SCRIPT), spec, action], + capture_output=True, text=True, timeout=15, + env={"PATH": "/usr/bin:/bin", "INSTALLER_DIR": str(stub_dir)}, + ) + + @pytest.mark.parametrize("spec,action,expected", [ + ("node@24", "uninstall", + ("install_node.sh action=uninstall", "NODE_VERSION=24")), + ("ruby@3.3", "uninstall", + ("install_ruby.sh action=uninstall", "RUBY_VERSION=3.3")), + ("go@1.26", "update", + ("install_go.sh action=update", "GO_VERSION=1.26")), + ("python@3.13", "install", + ("install_python.sh action=install", "UV_PYTHON_SPEC=3.13")), + ]) + def test_dispatches_with_cycle_env(self, tmp_path, spec, action, expected): + result = self._run(tmp_path, spec, action) + assert result.returncode == 0, result.stderr + for part in expected: + assert part in result.stdout + + def test_unsupported_tool_errors(self, tmp_path): + result = self._run(tmp_path, "php@8.4", "uninstall") + assert result.returncode == 1 + assert "no version-cycle support" in result.stderr + + def test_spec_without_cycle_errors(self, tmp_path): + result = subprocess.run( + ["bash", str(SCRIPT), "node", "uninstall"], + capture_output=True, text=True, timeout=15, + ) + assert result.returncode == 2 + assert "Usage" in result.stderr + + +@skip_on_windows +class TestMakeCycleTargets: + @pytest.mark.parametrize("target,expected_action", [ + ("uninstall-node@24", "uninstall"), + ("upgrade-node@24", "update"), + ("install-python@3.13", "install"), + ]) + def test_pattern_targets_branch_to_cycle_action(self, target, expected_action): + result = subprocess.run( + ["make", "-n", target], + capture_output=True, text=True, timeout=30, cwd=ROOT, + ) + assert result.returncode == 0, result.stderr + assert f'cycle_action.sh "$spec" {expected_action}' in result.stdout + + def test_plain_targets_unaffected(self): + result = subprocess.run( + ["make", "-n", "uninstall-ripgrep"], + capture_output=True, text=True, timeout=30, cwd=ROOT, + ) + assert result.returncode == 0, result.stderr + assert 'install_tool.sh "ripgrep" uninstall' in result.stdout