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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ This repo **is** a CLI tool manager, so the word "upgrade" is overloaded:
| `make upgrade-<tool>` | Upgrade any tool (e.g., `make upgrade-ripgrep`) |
| `make uninstall-<tool>` | Uninstall any tool (e.g., `make uninstall-jq`) |
| `make reconcile-<tool>` | Reconcile install method (e.g., `make reconcile-node`) |
| `make uninstall-<tool>@<cycle>` | Remove one runtime version cycle (e.g., `make uninstall-node@24`, `uninstall-ruby@3.3`) |
| `make install-<tool>@<cycle>` / `upgrade-<tool>@<cycle>` | 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 |
Expand Down
16 changes: 11 additions & 5 deletions Makefile.d/user.mk
Original file line number Diff line number Diff line change
Expand Up @@ -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; \
Expand Down Expand Up @@ -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; \
Expand All @@ -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; \
Expand Down
44 changes: 44 additions & 0 deletions scripts/cycle_action.sh
Original file line number Diff line number Diff line change
@@ -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

Check failure on line 13 in scripts/cycle_action.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9usYoThMyQDPZHLDcx&open=AZ9usYoThMyQDPZHLDcx&pullRequest=112

Check failure on line 13 in scripts/cycle_action.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9usYoThMyQDPZHLDcy&open=AZ9usYoThMyQDPZHLDcy&pullRequest=112
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

Check failure on line 22 in scripts/cycle_action.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9usYoThMyQDPZHLDc0&open=AZ9usYoThMyQDPZHLDc0&pullRequest=112

Check failure on line 22 in scripts/cycle_action.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=netresearch_coding_agent_cli_toolset&issues=AZ9usYoThMyQDPZHLDcz&open=AZ9usYoThMyQDPZHLDcz&pullRequest=112
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
97 changes: 97 additions & 0 deletions tests/test_cycle_targets.py
Original file line number Diff line number Diff line change
@@ -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
Loading