From a40c1fd410086ac27746e135df39183759da561f Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 12:44:15 +0000 Subject: [PATCH 01/34] fix(ENG-15139): refuse --mavenrc where the mvn script cannot use it The mvn script expands MAVEN_OPTS without quotes, so a JAR path with a space splits into two JVM arguments and every build fails. mvn.cmd on Windows does not read ~/.mavenrc. Stop `install maven --mavenrc` in both cases before it changes a file or downloads the JAR, and tell the user to use --local or Maven 3.10. The Maven 3.9 warning on Windows now names only --local. Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 6 ++- .../test_credential_helper_maven_tools.py | 38 +++++++++++++++++-- .../credential_helpers/maven/installer.py | 30 +++++++++++++-- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index e76fa75d..56a62454 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -20,7 +20,10 @@ from cloudsmith_cli.credential_helpers.gradle.installer import GradleInstaller from cloudsmith_cli.credential_helpers.launchers import UnsafeLauncherValue from cloudsmith_cli.credential_helpers.maven.extensions import ExtensionsFileError -from cloudsmith_cli.credential_helpers.maven.installer import MavenInstaller +from cloudsmith_cli.credential_helpers.maven.installer import ( + MavenInstaller, + MavenrcNotSupported, +) from cloudsmith_cli.credential_helpers.maven_tools import ( ConfigFileConflict, ProjectNotFound, @@ -447,6 +450,7 @@ def install_cmd( ExtensionsFileError, ConfigFileConflict, UnsafeLauncherValue, + MavenrcNotSupported, ) as exc: raise click.ClickException(str(exc)) except OSError as exc: diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 6246a6e3..f2239dcb 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -22,7 +22,7 @@ from ...credential_helpers.gradle.installer import GradleInstaller from ...credential_helpers.maven import extensions, mavenrc from ...credential_helpers.maven import installer as maven_installer -from ...credential_helpers.maven.installer import MavenInstaller +from ...credential_helpers.maven.installer import MavenInstaller, MavenrcNotSupported from ...credential_helpers.maven_tools import ConfigFileConflict, ProjectNotFound from ...credential_helpers.sbt.installer import SbtInstaller @@ -722,11 +722,13 @@ def test_maven_install_turns_on_the_extension_for_every_build( assert not [action for action in actions if action.startswith("WARNING")] +@pytest.mark.parametrize("windows", [False, True]) def test_maven_install_warns_when_maven_ignores_user_extensions( - home, fake_repository, outside_projects, monkeypatch + windows, home, fake_repository, outside_projects, monkeypatch ): """Maven 3.9 and earlier ignore ~/.m2/extensions.xml, so install names the options.""" monkeypatch.setattr(maven_installer, "_maven_version", lambda: "3.9.11") + monkeypatch.setattr(maven_installer, "is_windows", lambda: windows) actions = MavenInstaller().install() @@ -734,7 +736,8 @@ def test_maven_install_warns_when_maven_ignores_user_extensions( assert len(warnings) == 1 assert "3.9.11" in warnings[0] assert "--local" in warnings[0] - assert "--mavenrc" in warnings[0] + # mvn.cmd does not read ~/.mavenrc. + assert ("--mavenrc" in warnings[0]) is not windows assert _user_extensions(home).exists() @@ -834,6 +837,21 @@ def test_maven_install_with_mavenrc_adds_a_block( assert not _user_extensions(home).exists() +def test_maven_install_with_mavenrc_refuses_a_jar_path_with_a_space( + tmp_path, monkeypatch, fake_repository, outside_projects +): + """mvn splits MAVEN_OPTS at spaces, so the JAR path would break every build.""" + home = tmp_path / "John Smith" + home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) + + with pytest.raises(MavenrcNotSupported, match="--local"): + MavenInstaller().install(mavenrc=True) + + assert not (home / ".mavenrc").exists() + assert not fake_repository + + def test_maven_install_with_mavenrc_turns_off_the_user_extensions_file( home, fake_repository, outside_projects ): @@ -1212,6 +1230,20 @@ def test_cli_install_maven_with_mavenrc(home, fake_repository, outside_projects) assert (home / ".mavenrc").exists() +def test_cli_install_maven_with_mavenrc_fails_cleanly_on_windows( + home, fake_repository, outside_projects, monkeypatch +): + """mvn.cmd does not read ~/.mavenrc, so the command names the other options.""" + monkeypatch.setattr(maven_installer, "is_windows", lambda: True) + + result = _invoke("install", ["maven", "--mavenrc", "--no-discover"]) + + assert result.exit_code == 1 + assert "--local" in result.output + assert not (home / ".mavenrc").exists() + assert not fake_repository + + def test_cli_install_maven_local_outside_a_project_fails_cleanly( home, fake_repository, outside_projects ): diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index e5a3886d..ab0ad480 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING from .. import maven_tools +from ..launchers import is_windows from ..maven_tools import ProjectNotFound from . import extensions from .mavenrc import add_block, has_block, remove_block @@ -33,6 +34,15 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) MAVEN_VERSION_TIMEOUT_SECONDS = 60 +INSTEAD_OF_MAVENRC = ( + "Run `cloudsmith credential-helper install maven --local` in each project " + "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " + "`cloudsmith credential-helper install maven`." +) + + +class MavenrcNotSupported(Exception): + """Raised when the ``mvn`` script cannot load the extension from ``~/.mavenrc``.""" def _project_dir() -> Path: @@ -103,10 +113,13 @@ def _old_maven_warning() -> list[str]: warning = ( f"WARNING: Maven {version} ignores {_user_extensions_file()}. " "Maven 3.10 and later read it. For this Maven, run " - "`cloudsmith credential-helper install maven --local` in each project, " - "or run `cloudsmith credential-helper install maven --mavenrc` once." + "`cloudsmith credential-helper install maven --local` in each project" ) - return [warning] + if not is_windows(): + warning += ( + ", or run `cloudsmith credential-helper install maven --mavenrc` once" + ) + return [f"{warning}."] def _mark_installed(version_dir: Path, file_names: list[str]) -> None: @@ -179,10 +192,19 @@ def install( ) -> list[str]: """Put the extension in ``~/.m2/repository`` and turn it on.""" project_file = _project_extensions_file() if local else None + if mavenrc and is_windows(): + raise MavenrcNotSupported( + f"mvn.cmd on Windows does not read ~/.mavenrc. {INSTEAD_OF_MAVENRC}" + ) version = maven_tools.latest_version(EXTENSION_PATH, session) + jar = _version_dir(version) / f"{extensions.ARTIFACT_ID}-{version}.jar" + if mavenrc and any(char.isspace() for char in str(jar)): + raise MavenrcNotSupported( + "The mvn script splits MAVEN_OPTS at spaces, so it cannot load " + f"{jar}. {INSTEAD_OF_MAVENRC}" + ) actions = self._download(version, dry_run, session) if mavenrc: - jar = _version_dir(version) / f"{extensions.ARTIFACT_ID}-{version}.jar" return ( actions + _add(_mavenrc_file(), partial(add_block, jar=jar), version, dry_run) From fa48760503ecca8fff4cf94ed8ea6642180fa4db Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 12:44:46 +0000 Subject: [PATCH 02/34] fix(ENG-15139): put the Maven extension in the configured local repository Maven loads a build extension from extensions.xml only from Maven Central or the local repository. The installer always used ~/.m2/repository, so a custom local repository made Maven miss the extension. The installer now uses -Dmaven.repo.local from MAVEN_OPTS (and, with --local, from .mvn/jvm.config and .mvn/maven.config), then in ~/.m2/settings.xml, then ~/.m2/repository. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 82 ++++++++++++++++++ .../credential_helpers/maven/installer.py | 83 +++++++++++++++++-- 2 files changed, 158 insertions(+), 7 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 6246a6e3..7a7ea316 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -9,6 +9,7 @@ import errno import os +import re import subprocess from pathlib import Path from unittest.mock import Mock, patch @@ -18,6 +19,7 @@ import httpretty.core import pytest +from ...core.cache_utils import ConfigFileParseError from ...credential_helpers import maven_tools from ...credential_helpers.gradle.installer import GradleInstaller from ...credential_helpers.maven import extensions, mavenrc @@ -69,6 +71,7 @@ def home(tmp_path, monkeypatch): home_dir.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir)) monkeypatch.delenv("GRADLE_USER_HOME", raising=False) + monkeypatch.delenv("MAVEN_OPTS", raising=False) return home_dir @@ -698,6 +701,24 @@ def _user_extensions(home: Path) -> Path: return home / ".m2" / "extensions.xml" +def _write_settings(home: Path, local_repository: str) -> Path: + settings = home / ".m2" / "settings.xml" + settings.parent.mkdir() + settings.write_text( + '\n' + f" {local_repository}\n" + "\n", + encoding="utf-8", + ) + return settings + + +def _jar(repository: Path) -> Path: + return ( + repository / EXTENSION_PATH / "0.2.0" / "cloudsmith-maven-extension-0.2.0.jar" + ) + + def test_maven_install_puts_the_extension_in_the_local_repository( home, fake_repository, outside_projects ): @@ -764,6 +785,67 @@ def test_maven_install_marks_the_files_as_installed( ) +@pytest.mark.parametrize("local_repository", ["${user.home}/repo", "${env.M2_REPO}"]) +def test_maven_install_uses_the_local_repository_of_the_user_settings( + local_repository, home, fake_repository, outside_projects, monkeypatch +): + """Maven reads from ~/.m2/settings.xml, after interpolation.""" + monkeypatch.setenv("M2_REPO", str(home / "repo")) + _write_settings(home, local_repository) + + actions = MavenInstaller().install() + + assert _jar(home / "repo").exists() + assert f"downloaded {_jar(home).name} to {_jar(home / 'repo').parent}" in actions + + +def test_maven_install_prefers_maven_repo_local_in_maven_opts( + home, fake_repository, outside_projects, monkeypatch +): + """-Dmaven.repo.local overrides the settings, and --mavenrc loads the JAR there.""" + _write_settings(home, "${user.home}/repo") + monkeypatch.setenv("MAVEN_OPTS", f"-Xmx1g -Dmaven.repo.local={home / 'opts'}") + + MavenInstaller().install(mavenrc=True) + + assert _jar(home / "opts").exists() + text = (home / ".mavenrc").read_text(encoding="utf-8") + assert f"-Dmaven.ext.class.path={_jar(home / 'opts')}" in text + + +@pytest.mark.parametrize( + ("config_file", "maven_opts"), + [("jvm.config", ""), ("maven.config", "-Dmaven.repo.local=/elsewhere")], +) +def test_maven_install_local_reads_maven_repo_local_of_the_project( + config_file, maven_opts, home, fake_repository, maven_project, monkeypatch +): + """With local, the .mvn config files of the project can set the local repository.""" + monkeypatch.setenv("MAVEN_OPTS", maven_opts) + (maven_project / ".mvn").mkdir() + (maven_project / ".mvn" / config_file).write_text( + f"-Dmaven.repo.local={maven_project / 'repo'}\n", encoding="utf-8" + ) + + MavenInstaller().install(local=True) + + assert _jar(maven_project / "repo").exists() + + +@pytest.mark.parametrize("local_repository", ["${env.NOT_SET}", ""]) +def test_maven_install_with_unusable_settings_fails( + local_repository, home, fake_repository, outside_projects, monkeypatch +): + """A settings file that does not parse or does not resolve stops the install.""" + monkeypatch.delenv("NOT_SET", raising=False) + settings = _write_settings(home, local_repository) + + with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): + MavenInstaller().install() + + assert not fake_repository + + def test_maven_install_local_turns_on_the_extension_in_the_project( home, fake_repository, maven_project ): diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index e5a3886d..890a1ee7 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -13,13 +13,16 @@ from __future__ import annotations +import os import re import shutil import subprocess from functools import partial from pathlib import Path from typing import TYPE_CHECKING +from xml.etree import ElementTree +from ...core.cache_utils import _FIX_CONFIG_FILE_HINT, ConfigFileParseError from .. import maven_tools from ..maven_tools import ProjectNotFound from . import extensions @@ -33,6 +36,8 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) MAVEN_VERSION_TIMEOUT_SECONDS = 60 +_REPO_LOCAL_OPTION = re.compile(r"(?:^|\s)-Dmaven\.repo\.local=(\S+)") +_PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") def _project_dir() -> Path: @@ -63,8 +68,67 @@ def _mavenrc_file() -> Path: return Path.home() / ".mavenrc" -def _version_dir(version: str) -> Path: - return Path.home() / ".m2" / "repository" / EXTENSION_PATH / version +def _repo_local_option(local: bool) -> str | None: + """Return the last ``-Dmaven.repo.local`` value, which is the one Java uses. + + ``mvn`` puts ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and the arguments in + ``.mvn/maven.config`` override both. + """ + sources = [os.environ.get("MAVEN_OPTS", "")] + if local: + mvn_dir = _project_dir() / ".mvn" + sources = [ + _read(mvn_dir / "jvm.config") or "", + *sources, + _read(mvn_dir / "maven.config") or "", + ] + values = _REPO_LOCAL_OPTION.findall(" ".join(sources)) + return values[-1] if values else None + + +def _resolve_placeholder(match: re.Match[str], settings_file: Path) -> str: + name = match.group(1) + env_name = name.removeprefix("env.") + if name == "user.home": + return str(Path.home()) + if name.startswith("env.") and env_name in os.environ: + return os.environ[env_name] + raise ConfigFileParseError( + f"Cannot resolve {match.group(0)} in of {settings_file}." + f" {_FIX_CONFIG_FILE_HINT}" + ) + + +def _settings_local_repository() -> str | None: + """Return ```` of ``~/.m2/settings.xml``, or ``None`` when it has none.""" + settings_file = Path.home() / ".m2" / "settings.xml" + if not settings_file.exists(): + return None + try: + root = ElementTree.parse(settings_file).getroot() + except ElementTree.ParseError as exc: + raise ConfigFileParseError( + f"Cannot parse {settings_file} as XML: {exc}. {_FIX_CONFIG_FILE_HINT}" + ) from exc + # {*} matches the namespace of the Maven settings, or no namespace. + value = root.findtext("{*}localRepository", "").strip() + if not value: + return None + return _PLACEHOLDER.sub( + partial(_resolve_placeholder, settings_file=settings_file), value + ) + + +def _local_repository(local: bool) -> Path: + """Return the local repository that Maven uses, in the same order of precedence.""" + location = _repo_local_option(local) or _settings_local_repository() + if location is None: + return Path.home() / ".m2" / "repository" + return Path(location).absolute() + + +def _version_dir(repository: Path, version: str) -> Path: + return repository / EXTENSION_PATH / version def _read(path: Path) -> str | None: @@ -177,12 +241,14 @@ def install( session: requests.Session | None = None, **_options, ) -> list[str]: - """Put the extension in ``~/.m2/repository`` and turn it on.""" + """Put the extension in the local repository of Maven and turn it on.""" project_file = _project_extensions_file() if local else None + repository = _local_repository(local) version = maven_tools.latest_version(EXTENSION_PATH, session) - actions = self._download(version, dry_run, session) + version_dir = _version_dir(repository, version) + actions = self._download(version_dir, version, dry_run, session) if mavenrc: - jar = _version_dir(version) / f"{extensions.ARTIFACT_ID}-{version}.jar" + jar = version_dir / f"{extensions.ARTIFACT_ID}-{version}.jar" return ( actions + _add(_mavenrc_file(), partial(add_block, jar=jar), version, dry_run) @@ -201,9 +267,12 @@ def install( ) def _download( - self, version: str, dry_run: bool, session: requests.Session | None + self, + version_dir: Path, + version: str, + dry_run: bool, + session: requests.Session | None, ) -> list[str]: - version_dir = _version_dir(version) file_names = [ f"{extensions.ARTIFACT_ID}-{version}.{file_type}" for file_type in ("jar", "pom") From 9ee501ffe40f713030c54fe1be3abaf82aa4d6d8 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 15:18:32 +0000 Subject: [PATCH 03/34] fix: address review on --mavenrc path checks Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 22 ++++++++++++++----- .../credential_helpers/maven/installer.py | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index f2239dcb..c27c0faf 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -10,6 +10,7 @@ import errno import os import subprocess +from contextlib import nullcontext from pathlib import Path from unittest.mock import Mock, patch @@ -95,6 +96,12 @@ def _maven_3_10(monkeypatch): monkeypatch.setattr(maven_installer, "_maven_version", lambda: "3.10.0") +@pytest.fixture(autouse=True) +def _posix(monkeypatch): + """Run as if the platform is not Windows, so the mvn script reads ~/.mavenrc.""" + monkeypatch.setattr(maven_installer, "is_windows", lambda: False) + + @pytest.fixture() def outside_projects(tmp_path, monkeypatch): """Run the test from a directory that is not in a project.""" @@ -837,19 +844,24 @@ def test_maven_install_with_mavenrc_adds_a_block( assert not _user_extensions(home).exists() +@pytest.mark.parametrize(("separator", "refused"), [(" ", True), ("\u00a0", False)]) def test_maven_install_with_mavenrc_refuses_a_jar_path_with_a_space( - tmp_path, monkeypatch, fake_repository, outside_projects + separator, refused, tmp_path, monkeypatch, fake_repository, outside_projects ): """mvn splits MAVEN_OPTS at spaces, so the JAR path would break every build.""" - home = tmp_path / "John Smith" + home = tmp_path / f"John{separator}Smith" home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) - with pytest.raises(MavenrcNotSupported, match="--local"): + with ( + pytest.raises(MavenrcNotSupported, match="--local") + if refused + else nullcontext() + ): MavenInstaller().install(mavenrc=True) - assert not (home / ".mavenrc").exists() - assert not fake_repository + assert (home / ".mavenrc").exists() is not refused + assert bool(fake_repository) is not refused def test_maven_install_with_mavenrc_turns_off_the_user_extensions_file( diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index ab0ad480..37fc715f 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -198,7 +198,7 @@ def install( ) version = maven_tools.latest_version(EXTENSION_PATH, session) jar = _version_dir(version) / f"{extensions.ARTIFACT_ID}-{version}.jar" - if mavenrc and any(char.isspace() for char in str(jar)): + if mavenrc and any(char in " \t\n" for char in str(jar)): raise MavenrcNotSupported( "The mvn script splits MAVEN_OPTS at spaces, so it cannot load " f"{jar}. {INSTEAD_OF_MAVENRC}" From f6e0e477995c0652cc8cf697a6daac870c8784bc Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 15:19:26 +0000 Subject: [PATCH 04/34] fix: read .mvn/maven.config the way Maven does Maven 3.9 reads each line of .mvn/maven.config as one argument and skips lines that start with #. The installer split the file on whitespace, so a comment could set the local repository and a path with a space was cut. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 22 +++++++++++++++++++ .../credential_helpers/maven/installer.py | 12 ++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 7a7ea316..9d75720f 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -832,6 +832,28 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( assert _jar(maven_project / "repo").exists() +@pytest.mark.parametrize( + ("maven_config", "repository"), + [ + ("# -Dmaven.repo.local=old-repo", "opts"), + ("-Dmaven.repo.local=my repo", "my repo"), + ], +) +def test_maven_install_local_reads_maven_config_one_argument_per_line( + maven_config, repository, home, fake_repository, maven_project, monkeypatch +): + """Maven skips # lines in .mvn/maven.config and reads each other line as one argument.""" + monkeypatch.setenv("MAVEN_OPTS", f"-Dmaven.repo.local={maven_project / 'opts'}") + (maven_project / ".mvn").mkdir() + (maven_project / ".mvn" / "maven.config").write_text( + f"{maven_config}\n", encoding="utf-8" + ) + + MavenInstaller().install(local=True) + + assert _jar(maven_project / repository).exists() + + @pytest.mark.parametrize("local_repository", ["${env.NOT_SET}", ""]) def test_maven_install_with_unusable_settings_fails( local_repository, home, fake_repository, outside_projects, monkeypatch diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 890a1ee7..9c5fa36e 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -36,6 +36,7 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) MAVEN_VERSION_TIMEOUT_SECONDS = 60 +_REPO_LOCAL_ARGUMENT = "-Dmaven.repo.local=" _REPO_LOCAL_OPTION = re.compile(r"(?:^|\s)-Dmaven\.repo\.local=(\S+)") _PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") @@ -72,17 +73,24 @@ def _repo_local_option(local: bool) -> str | None: """Return the last ``-Dmaven.repo.local`` value, which is the one Java uses. ``mvn`` puts ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and the arguments in - ``.mvn/maven.config`` override both. + ``.mvn/maven.config`` override both. Each line of ``.mvn/maven.config`` is one + argument, and Maven skips the lines that start with ``#``. """ sources = [os.environ.get("MAVEN_OPTS", "")] + maven_config = "" if local: mvn_dir = _project_dir() / ".mvn" sources = [ _read(mvn_dir / "jvm.config") or "", *sources, - _read(mvn_dir / "maven.config") or "", ] + maven_config = _read(mvn_dir / "maven.config") or "" values = _REPO_LOCAL_OPTION.findall(" ".join(sources)) + values += [ + line.removeprefix(_REPO_LOCAL_ARGUMENT) + for line in maven_config.splitlines() + if line.startswith(_REPO_LOCAL_ARGUMENT) + ] return values[-1] if values else None From 418f26808544281d4e08d8a257f3fe54ea055666 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 15:26:10 +0000 Subject: [PATCH 05/34] fix: split maven.config on whitespace for Maven before 3.9 Maven 3.8 splits .mvn/maven.config on whitespace, so one line can hold several options. With --local, the installer now asks mvn -v for the version when maven.config is not empty, and splits the file on whitespace for Maven before 3.9. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 20 +++++++++++++------ .../credential_helpers/maven/installer.py | 20 ++++++++++++++++--- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 9d75720f..8d43622a 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -833,16 +833,24 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( @pytest.mark.parametrize( - ("maven_config", "repository"), + ("maven_version", "maven_config", "repository"), [ - ("# -Dmaven.repo.local=old-repo", "opts"), - ("-Dmaven.repo.local=my repo", "my repo"), + ("3.9.3", "# -Dmaven.repo.local=old-repo", "opts"), + ("3.9.3", "-Dmaven.repo.local=my repo", "my repo"), + ("3.8.8", "-Dmaven.repo.local=m2 -B", "m2"), ], ) -def test_maven_install_local_reads_maven_config_one_argument_per_line( - maven_config, repository, home, fake_repository, maven_project, monkeypatch +def test_maven_install_local_reads_maven_config_like_maven( + maven_version, + maven_config, + repository, + home, + fake_repository, + maven_project, + monkeypatch, ): - """Maven skips # lines in .mvn/maven.config and reads each other line as one argument.""" + """Maven 3.9 reads each .mvn/maven.config line as one argument; 3.8 splits on whitespace.""" + monkeypatch.setattr(maven_installer, "_maven_version", lambda: maven_version) monkeypatch.setenv("MAVEN_OPTS", f"-Dmaven.repo.local={maven_project / 'opts'}") (maven_project / ".mvn").mkdir() (maven_project / ".mvn" / "maven.config").write_text( diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 9c5fa36e..1e1816dc 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -35,6 +35,7 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) +FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE = (3, 9) MAVEN_VERSION_TIMEOUT_SECONDS = 60 _REPO_LOCAL_ARGUMENT = "-Dmaven.repo.local=" _REPO_LOCAL_OPTION = re.compile(r"(?:^|\s)-Dmaven\.repo\.local=(\S+)") @@ -74,7 +75,8 @@ def _repo_local_option(local: bool) -> str | None: ``mvn`` puts ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and the arguments in ``.mvn/maven.config`` override both. Each line of ``.mvn/maven.config`` is one - argument, and Maven skips the lines that start with ``#``. + argument, and Maven skips the lines that start with ``#``. Maven before 3.9 + splits ``.mvn/maven.config`` on whitespace instead. """ sources = [os.environ.get("MAVEN_OPTS", "")] maven_config = "" @@ -85,6 +87,9 @@ def _repo_local_option(local: bool) -> str | None: *sources, ] maven_config = _read(mvn_dir / "maven.config") or "" + if maven_config and _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): + sources.append(maven_config) + maven_config = "" values = _REPO_LOCAL_OPTION.findall(" ".join(sources)) values += [ line.removeprefix(_REPO_LOCAL_ARGUMENT) @@ -165,12 +170,21 @@ def _maven_version() -> str | None: return match.group(1) if match else None +def _major_minor(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")[:2]) + + +def _maven_older_than(major_minor: tuple[int, int]) -> bool: + """Return ``True`` when ``mvn -v`` reports a version older than ``major_minor``.""" + version = _maven_version() + return version is not None and _major_minor(version) < major_minor + + def _old_maven_warning() -> list[str]: version = _maven_version() if version is None: return [] - major_minor = tuple(int(part) for part in version.split(".")[:2]) - if major_minor >= FIRST_MAVEN_WITH_USER_EXTENSIONS: + if _major_minor(version) >= FIRST_MAVEN_WITH_USER_EXTENSIONS: return [] warning = ( f"WARNING: Maven {version} ignores {_user_extensions_file()}. " From df6bd3aad3f8015adf281e276ff62c1545cc0e5a Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 16:05:07 +0000 Subject: [PATCH 06/34] docs: say where --mavenrc does not work in the CHANGELOG Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbf17a4..49108e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added `cloudsmith credential-helper install maven`, `install gradle`, and `install sbt`. They set up the Cloudsmith Maven tools, which get repository credentials from `cloudsmith credential-helper generic`. Each command installs the newest release from the public `cloudsmith/maven-tools` repository, for every build of the user. - - `maven` puts the extension in `~/.m2/repository` and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. + - `maven` puts the extension in `~/.m2/repository` and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space. In these cases the command stops and tells you to use `--local` or Maven 3.10. - `gradle` writes an init script to `~/.gradle/init.d` (or `$GRADLE_USER_HOME/init.d`), which applies the plugin to every Gradle build. - `sbt` writes a global plugin to `~/.sbt/1.0/plugins`, which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. From 7dbea592f86c7cc9b2093e3be73e1b69c2278832 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:18:38 +0000 Subject: [PATCH 07/34] docs: say the Maven extension goes in the configured local repository Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49108e81..ffe09a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added `cloudsmith credential-helper install maven`, `install gradle`, and `install sbt`. They set up the Cloudsmith Maven tools, which get repository credentials from `cloudsmith credential-helper generic`. Each command installs the newest release from the public `cloudsmith/maven-tools` repository, for every build of the user. - - `maven` puts the extension in `~/.m2/repository` and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space. In these cases the command stops and tells you to use `--local` or Maven 3.10. + - `maven` puts the extension in the Maven local repository (`-Dmaven.repo.local`, then `` in `~/.m2/settings.xml`, then `~/.m2/repository`) and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space. In these cases the command stops and tells you to use `--local` or Maven 3.10. - `gradle` writes an init script to `~/.gradle/init.d` (or `$GRADLE_USER_HOME/init.d`), which applies the plugin to every Gradle build. - `sbt` writes a global plugin to `~/.sbt/1.0/plugins`, which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. From b7ce50cfec600ed97168f0e5fb87c3822aa77337 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:19:09 +0000 Subject: [PATCH 08/34] chore: push again so GitHub picks up the CHANGELOG commit Co-Authored-By: Claude Opus 5.5 From b054ee39cfd2c9b11050c9b3f2be844558d44506 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:25:26 +0000 Subject: [PATCH 09/34] fix: split MAVEN_OPTS the way mvn and mvn.cmd do mvn splits only at spaces, tabs and new lines, so a no-break space stays in the path. mvn.cmd passes the options to java.exe, which keeps quoted text as one argument. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 19 ++++++++++ .../credential_helpers/maven/installer.py | 36 +++++++++++-------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index b85d77f3..88d00134 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -843,6 +843,25 @@ def test_maven_install_prefers_maven_repo_local_in_maven_opts( assert f"-Dmaven.ext.class.path={_jar(home / 'opts')}" in text +@pytest.mark.parametrize( + ("windows", "quote", "directory"), + [(False, "", "no break"), (True, '"', "my repo")], +) +def test_maven_install_splits_maven_opts_like_the_mvn_script( + windows, quote, directory, home, fake_repository, outside_projects, monkeypatch +): + """mvn splits MAVEN_OPTS only at spaces, tabs and new lines; mvn.cmd keeps quoted text together.""" + monkeypatch.setattr(maven_installer, "is_windows", lambda: windows) + repository = home / directory + monkeypatch.setenv( + "MAVEN_OPTS", f"{quote}-Dmaven.repo.local={repository}{quote} -B" + ) + + MavenInstaller().install() + + assert _jar(repository).exists() + + @pytest.mark.parametrize( ("config_file", "maven_opts"), [("jvm.config", ""), ("maven.config", "-Dmaven.repo.local=/elsewhere")], diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 39f0311d..4efef9db 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -40,7 +40,9 @@ FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE = (3, 9) MAVEN_VERSION_TIMEOUT_SECONDS = 60 _REPO_LOCAL_ARGUMENT = "-Dmaven.repo.local=" -_REPO_LOCAL_OPTION = re.compile(r"(?:^|\s)-Dmaven\.repo\.local=(\S+)") +_POSIX_JVM_ARGUMENT = re.compile(r"[^ \t\r\n]+") +_WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') +_JAVA_SPLIT_ARGUMENT = re.compile(r"\S+", re.ASCII) _PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " @@ -81,6 +83,16 @@ def _mavenrc_file() -> Path: return Path.home() / ".mavenrc" +def _jvm_arguments(options: str) -> list[str]: + """Split JVM options at spaces, tabs and new lines; ``mvn.cmd`` keeps quoted text together.""" + if is_windows(): + return [ + argument.replace('"', "") + for argument in _WINDOWS_JVM_ARGUMENT.findall(options) + ] + return _POSIX_JVM_ARGUMENT.findall(options) + + def _repo_local_option(local: bool) -> str | None: """Return the last ``-Dmaven.repo.local`` value, which is the one Java uses. @@ -89,23 +101,19 @@ def _repo_local_option(local: bool) -> str | None: argument, and Maven skips the lines that start with ``#``. Maven before 3.9 splits ``.mvn/maven.config`` on whitespace instead. """ - sources = [os.environ.get("MAVEN_OPTS", "")] - maven_config = "" + jvm_options = os.environ.get("MAVEN_OPTS", "") + maven_arguments: list[str] = [] if local: mvn_dir = _project_dir() / ".mvn" - sources = [ - _read(mvn_dir / "jvm.config") or "", - *sources, - ] + jvm_options = f"{_read(mvn_dir / 'jvm.config') or ''} {jvm_options}" maven_config = _read(mvn_dir / "maven.config") or "" + maven_arguments = maven_config.splitlines() if maven_config and _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): - sources.append(maven_config) - maven_config = "" - values = _REPO_LOCAL_OPTION.findall(" ".join(sources)) - values += [ - line.removeprefix(_REPO_LOCAL_ARGUMENT) - for line in maven_config.splitlines() - if line.startswith(_REPO_LOCAL_ARGUMENT) + maven_arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) + values = [ + argument.removeprefix(_REPO_LOCAL_ARGUMENT) + for argument in [*_jvm_arguments(jvm_options), *maven_arguments] + if argument.startswith(_REPO_LOCAL_ARGUMENT) ] return values[-1] if values else None From c04f3dfc131dfcba5afc652f3a1a423bf7c66d6a Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:40:18 +0000 Subject: [PATCH 10/34] fix: read --define and a split -D in .mvn/maven.config Maven also takes --define=name=value, and -D or --define with the value in the next argument. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 2 ++ .../credential_helpers/maven/installer.py | 36 ++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 88d00134..3a0eb213 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -886,6 +886,8 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( [ ("3.9.3", "# -Dmaven.repo.local=old-repo", "opts"), ("3.9.3", "-Dmaven.repo.local=my repo", "my repo"), + ("3.9.3", "--define=maven.repo.local=defined", "defined"), + ("3.9.3", "-D\nmaven.repo.local=next line", "next line"), ("3.8.8", "-Dmaven.repo.local=m2 -B", "m2"), ], ) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 4efef9db..7620e5d0 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -39,7 +39,8 @@ FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE = (3, 9) MAVEN_VERSION_TIMEOUT_SECONDS = 60 -_REPO_LOCAL_ARGUMENT = "-Dmaven.repo.local=" +_REPO_LOCAL_PROPERTY = "maven.repo.local=" +_MAVEN_DEFINE_OPTIONS = ("-D", "--define") _POSIX_JVM_ARGUMENT = re.compile(r"[^ \t\r\n]+") _WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') _JAVA_SPLIT_ARGUMENT = re.compile(r"\S+", re.ASCII) @@ -93,6 +94,19 @@ def _jvm_arguments(options: str) -> list[str]: return _POSIX_JVM_ARGUMENT.findall(options) +def _maven_definitions(arguments: list[str]) -> list[str]: + """Return the ``name=value`` of each ``-D`` or ``--define`` option in Maven arguments.""" + definitions = [] + for previous, argument in zip(["", *arguments], arguments): + if previous in _MAVEN_DEFINE_OPTIONS: + definitions.append(argument) + elif argument.startswith("--define="): + definitions.append(argument.removeprefix("--define=")) + elif argument.startswith("-D") and argument != "-D": + definitions.append(argument.removeprefix("-D")) + return definitions + + def _repo_local_option(local: bool) -> str | None: """Return the last ``-Dmaven.repo.local`` value, which is the one Java uses. @@ -106,14 +120,20 @@ def _repo_local_option(local: bool) -> str | None: if local: mvn_dir = _project_dir() / ".mvn" jvm_options = f"{_read(mvn_dir / 'jvm.config') or ''} {jvm_options}" - maven_config = _read(mvn_dir / "maven.config") or "" - maven_arguments = maven_config.splitlines() - if maven_config and _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): - maven_arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) + maven_config = _read(mvn_dir / "maven.config") + if maven_config: + maven_arguments = maven_config.splitlines() + if _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): + maven_arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) + definitions = [ + argument.removeprefix("-D") + for argument in _jvm_arguments(jvm_options) + if argument.startswith("-D") + ] + _maven_definitions(maven_arguments) values = [ - argument.removeprefix(_REPO_LOCAL_ARGUMENT) - for argument in [*_jvm_arguments(jvm_options), *maven_arguments] - if argument.startswith(_REPO_LOCAL_ARGUMENT) + definition.removeprefix(_REPO_LOCAL_PROPERTY) + for definition in definitions + if definition.startswith(_REPO_LOCAL_PROPERTY) ] return values[-1] if values else None From 672497d2827216308302af81ed663ad41cfe1d24 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:47:50 +0000 Subject: [PATCH 11/34] fix: resolve placeholders in maven.repo.local of .mvn/maven.config Maven trims the name of a -D property and interpolates its value with ${session.rootDirectory}, ${session.topDirectory}, system properties, ${env.NAME} and the other -D properties. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 3 + .../credential_helpers/maven/installer.py | 104 ++++++++++++------ 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 3a0eb213..57f5cdab 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -888,6 +888,9 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( ("3.9.3", "-Dmaven.repo.local=my repo", "my repo"), ("3.9.3", "--define=maven.repo.local=defined", "defined"), ("3.9.3", "-D\nmaven.repo.local=next line", "next line"), + ("3.9.3", "-D maven.repo.local=trimmed", "trimmed"), + ("3.9.3", "-Dmaven.repo.local=${session.rootDirectory}/root", "root"), + ("3.9.3", "-Dbase=based\n-Dmaven.repo.local=${base}/repo", "based/repo"), ("3.8.8", "-Dmaven.repo.local=m2 -B", "m2"), ], ) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 7620e5d0..37b4a58d 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -39,7 +39,7 @@ FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE = (3, 9) MAVEN_VERSION_TIMEOUT_SECONDS = 60 -_REPO_LOCAL_PROPERTY = "maven.repo.local=" +_REPO_LOCAL_PROPERTY = "maven.repo.local" _MAVEN_DEFINE_OPTIONS = ("-D", "--define") _POSIX_JVM_ARGUMENT = re.compile(r"[^ \t\r\n]+") _WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') @@ -107,47 +107,84 @@ def _maven_definitions(arguments: list[str]) -> list[str]: return definitions +def _properties(definitions: list[str]) -> dict[str, str]: + """Return the properties of ``name=value`` definitions; Maven trims each name.""" + properties = {} + for definition in definitions: + name, separator, value = definition.partition("=") + if separator: + properties[name.strip()] = value + return properties + + +def _maven_config_repo_local(mvn_dir: Path) -> str | None: + """Return ``maven.repo.local`` of ``.mvn/maven.config`` with its placeholders resolved. + + Each line of ``.mvn/maven.config`` is one argument, and Maven skips the lines + that start with ``#``. Maven before 3.9 splits the file on whitespace instead. + """ + maven_config_file = mvn_dir / "maven.config" + maven_config = _read(maven_config_file) + if not maven_config: + return None + arguments = maven_config.splitlines() + if _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): + arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) + defined = _properties(_maven_definitions(arguments)) + if _REPO_LOCAL_PROPERTY not in defined: + return None + root = str(mvn_dir.parent) + properties = { + "user.home": str(Path.home()), + "maven.multiModuleProjectDirectory": root, + "session.rootDirectory": root, + "session.topDirectory": str(maven_tools.current_dir()), + **defined, + } + return _PLACEHOLDER.sub( + partial( + _resolve_placeholder, + where=f"{_REPO_LOCAL_PROPERTY} of {maven_config_file}", + properties=properties, + ), + defined[_REPO_LOCAL_PROPERTY], + ) + + def _repo_local_option(local: bool) -> str | None: - """Return the last ``-Dmaven.repo.local`` value, which is the one Java uses. + """Return the ``maven.repo.local`` value that Maven uses. - ``mvn`` puts ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and the arguments in - ``.mvn/maven.config`` override both. Each line of ``.mvn/maven.config`` is one - argument, and Maven skips the lines that start with ``#``. Maven before 3.9 - splits ``.mvn/maven.config`` on whitespace instead. + ``.mvn/maven.config`` overrides the JVM options. ``mvn`` puts + ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and Java uses the last value. """ jvm_options = os.environ.get("MAVEN_OPTS", "") - maven_arguments: list[str] = [] if local: mvn_dir = _project_dir() / ".mvn" + maven_config_value = _maven_config_repo_local(mvn_dir) + if maven_config_value is not None: + return maven_config_value jvm_options = f"{_read(mvn_dir / 'jvm.config') or ''} {jvm_options}" - maven_config = _read(mvn_dir / "maven.config") - if maven_config: - maven_arguments = maven_config.splitlines() - if _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): - maven_arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) - definitions = [ - argument.removeprefix("-D") - for argument in _jvm_arguments(jvm_options) - if argument.startswith("-D") - ] + _maven_definitions(maven_arguments) - values = [ - definition.removeprefix(_REPO_LOCAL_PROPERTY) - for definition in definitions - if definition.startswith(_REPO_LOCAL_PROPERTY) - ] - return values[-1] if values else None - - -def _resolve_placeholder(match: re.Match[str], settings_file: Path) -> str: + jvm_properties = _properties( + [ + argument.removeprefix("-D") + for argument in _jvm_arguments(jvm_options) + if argument.startswith("-D") + ] + ) + return jvm_properties.get(_REPO_LOCAL_PROPERTY) + + +def _resolve_placeholder( + match: re.Match[str], where: str, properties: dict[str, str] +) -> str: name = match.group(1) env_name = name.removeprefix("env.") - if name == "user.home": - return str(Path.home()) + if name in properties: + return properties[name] if name.startswith("env.") and env_name in os.environ: return os.environ[env_name] raise ConfigFileParseError( - f"Cannot resolve {match.group(0)} in of {settings_file}." - f" {_FIX_CONFIG_FILE_HINT}" + f"Cannot resolve {match.group(0)} in {where}. {_FIX_CONFIG_FILE_HINT}" ) @@ -167,7 +204,12 @@ def _settings_local_repository() -> str | None: if not value: return None return _PLACEHOLDER.sub( - partial(_resolve_placeholder, settings_file=settings_file), value + partial( + _resolve_placeholder, + where=f" of {settings_file}", + properties={"user.home": str(Path.home())}, + ), + value, ) From c2825276173051540b435fb0a42ad0b1b5dac163 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:02:57 +0000 Subject: [PATCH 12/34] fix: resolve maven.config placeholders like Maven and use the mvnw version Resolve the placeholders in a property value too, and stop at a property that refers to itself. Resolve placeholders from the JVM -D options. Split .mvn/maven.config for the Maven version of .mvn/wrapper when it has one. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 36 ++++++++- .../credential_helpers/maven/installer.py | 79 +++++++++++++------ 2 files changed, 87 insertions(+), 28 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 57f5cdab..d23d9d60 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -891,6 +891,12 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( ("3.9.3", "-D maven.repo.local=trimmed", "trimmed"), ("3.9.3", "-Dmaven.repo.local=${session.rootDirectory}/root", "root"), ("3.9.3", "-Dbase=based\n-Dmaven.repo.local=${base}/repo", "based/repo"), + ( + "3.9.3", + "-Dbase=${session.rootDirectory}/cache\n-Dmaven.repo.local=${base}/repo", + "cache/repo", + ), + ("3.9.3", "-Dmaven.repo.local=${repo.base}/m2", "based/m2"), ("3.8.8", "-Dmaven.repo.local=m2 -B", "m2"), ], ) @@ -905,7 +911,10 @@ def test_maven_install_local_reads_maven_config_like_maven( ): """Maven 3.9 reads each .mvn/maven.config line as one argument; 3.8 splits on whitespace.""" monkeypatch.setattr(maven_installer, "_maven_version", lambda: maven_version) - monkeypatch.setenv("MAVEN_OPTS", f"-Dmaven.repo.local={maven_project / 'opts'}") + monkeypatch.setenv( + "MAVEN_OPTS", + f"-Drepo.base=based -Dmaven.repo.local={maven_project / 'opts'}", + ) (maven_project / ".mvn").mkdir() (maven_project / ".mvn" / "maven.config").write_text( f"{maven_config}\n", encoding="utf-8" @@ -916,12 +925,35 @@ def test_maven_install_local_reads_maven_config_like_maven( assert _jar(maven_project / repository).exists() -@pytest.mark.parametrize("local_repository", ["${env.NOT_SET}", ""]) +def test_maven_install_local_splits_maven_config_for_the_maven_wrapper( + home, fake_repository, maven_project +): + """The Maven of .mvn/wrapper, not the mvn on PATH, decides how maven.config is split.""" + wrapper_dir = maven_project / ".mvn" / "wrapper" + wrapper_dir.mkdir(parents=True) + (wrapper_dir / "maven-wrapper.properties").write_text( + "distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven" + "/apache-maven/3.8.8/apache-maven-3.8.8-bin.zip\n", + encoding="utf-8", + ) + (maven_project / ".mvn" / "maven.config").write_text( + "-Dmaven.repo.local=m2 -B\n", encoding="utf-8" + ) + + MavenInstaller().install(local=True) + + assert _jar(maven_project / "m2").exists() + + +@pytest.mark.parametrize( + "local_repository", ["${env.NOT_SET}", "${env.CYCLE}", ""] +) def test_maven_install_with_unusable_settings_fails( local_repository, home, fake_repository, outside_projects, monkeypatch ): """A settings file that does not parse or does not resolve stops the install.""" monkeypatch.delenv("NOT_SET", raising=False) + monkeypatch.setenv("CYCLE", "${env.CYCLE}") settings = _write_settings(home, local_repository) with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 37b4a58d..5e958a85 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -45,6 +45,9 @@ _WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') _JAVA_SPLIT_ARGUMENT = re.compile(r"\S+", re.ASCII) _PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") +_WRAPPER_MAVEN_VERSION = re.compile( + r"^distributionUrl\s*[=:].*/apache-maven/([\w.-]+)/", re.MULTILINE +) INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -117,18 +120,29 @@ def _properties(definitions: list[str]) -> dict[str, str]: return properties -def _maven_config_repo_local(mvn_dir: Path) -> str | None: +def _wrapper_maven_version(mvn_dir: Path) -> str | None: + """Return the Maven version that ``mvnw`` downloads, or ``None`` without a wrapper.""" + properties = _read(mvn_dir / "wrapper" / "maven-wrapper.properties") + match = _WRAPPER_MAVEN_VERSION.search(properties) if properties else None + return match.group(1) if match else None + + +def _maven_config_repo_local( + mvn_dir: Path, jvm_properties: dict[str, str] +) -> str | None: """Return ``maven.repo.local`` of ``.mvn/maven.config`` with its placeholders resolved. Each line of ``.mvn/maven.config`` is one argument, and Maven skips the lines that start with ``#``. Maven before 3.9 splits the file on whitespace instead. + The Maven of ``mvnw`` decides, else the ``mvn`` on ``PATH``. """ maven_config_file = mvn_dir / "maven.config" maven_config = _read(maven_config_file) if not maven_config: return None arguments = maven_config.splitlines() - if _maven_older_than(FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE): + version = _wrapper_maven_version(mvn_dir) or _maven_version() + if version and _major_minor(version) < FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE: arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) defined = _properties(_maven_definitions(arguments)) if _REPO_LOCAL_PROPERTY not in defined: @@ -137,17 +151,15 @@ def _maven_config_repo_local(mvn_dir: Path) -> str | None: properties = { "user.home": str(Path.home()), "maven.multiModuleProjectDirectory": root, + **jvm_properties, + **defined, "session.rootDirectory": root, "session.topDirectory": str(maven_tools.current_dir()), - **defined, } - return _PLACEHOLDER.sub( - partial( - _resolve_placeholder, - where=f"{_REPO_LOCAL_PROPERTY} of {maven_config_file}", - properties=properties, - ), + return _interpolate( defined[_REPO_LOCAL_PROPERTY], + f"{_REPO_LOCAL_PROPERTY} of {maven_config_file}", + properties, ) @@ -158,11 +170,8 @@ def _repo_local_option(local: bool) -> str | None: ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and Java uses the last value. """ jvm_options = os.environ.get("MAVEN_OPTS", "") - if local: - mvn_dir = _project_dir() / ".mvn" - maven_config_value = _maven_config_repo_local(mvn_dir) - if maven_config_value is not None: - return maven_config_value + mvn_dir = _project_dir() / ".mvn" if local else None + if mvn_dir: jvm_options = f"{_read(mvn_dir / 'jvm.config') or ''} {jvm_options}" jvm_properties = _properties( [ @@ -171,6 +180,10 @@ def _repo_local_option(local: bool) -> str | None: if argument.startswith("-D") ] ) + if mvn_dir: + maven_config_value = _maven_config_repo_local(mvn_dir, jvm_properties) + if maven_config_value is not None: + return maven_config_value return jvm_properties.get(_REPO_LOCAL_PROPERTY) @@ -188,6 +201,29 @@ def _resolve_placeholder( ) +def _interpolate( + value: str, + where: str, + properties: dict[str, str], + resolving: frozenset[str] = frozenset(), +) -> str: + """Replace each ``${name}`` in ``value``, and then the placeholders in its value, as Maven does.""" + + def replace(match: re.Match[str]) -> str: + if match.group(1) in resolving: + raise ConfigFileParseError( + f"{match.group(0)} refers to itself in {where}. {_FIX_CONFIG_FILE_HINT}" + ) + return _interpolate( + _resolve_placeholder(match, where, properties), + where, + properties, + resolving | {match.group(1)}, + ) + + return _PLACEHOLDER.sub(replace, value) + + def _settings_local_repository() -> str | None: """Return ```` of ``~/.m2/settings.xml``, or ``None`` when it has none.""" settings_file = Path.home() / ".m2" / "settings.xml" @@ -203,13 +239,10 @@ def _settings_local_repository() -> str | None: value = root.findtext("{*}localRepository", "").strip() if not value: return None - return _PLACEHOLDER.sub( - partial( - _resolve_placeholder, - where=f" of {settings_file}", - properties={"user.home": str(Path.home())}, - ), + return _interpolate( value, + f" of {settings_file}", + {"user.home": str(Path.home())}, ) @@ -255,12 +288,6 @@ def _major_minor(version: str) -> tuple[int, ...]: return tuple(int(part) for part in version.split(".")[:2]) -def _maven_older_than(major_minor: tuple[int, int]) -> bool: - """Return ``True`` when ``mvn -v`` reports a version older than ``major_minor``.""" - version = _maven_version() - return version is not None and _major_minor(version) < major_minor - - def _old_maven_warning() -> list[str]: version = _maven_version() if version is None: From b1b849f3ccb04686909b226fb46a6434502eb131 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:09:38 +0000 Subject: [PATCH 13/34] fix: skip blank lines in maven.config and read the wrapper properties like Java Maven drops empty and comment lines of .mvn/maven.config before it parses them, and Java splits lines only at CR and LF. In maven-wrapper.properties, a key can follow spaces and use : or a space as the separator, and the last distributionUrl wins. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_credential_helper_maven_tools.py | 16 ++++++++++++---- .../credential_helpers/maven/installer.py | 15 +++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index d23d9d60..d9cb55e6 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -888,6 +888,7 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( ("3.9.3", "-Dmaven.repo.local=my repo", "my repo"), ("3.9.3", "--define=maven.repo.local=defined", "defined"), ("3.9.3", "-D\nmaven.repo.local=next line", "next line"), + ("3.9.3", "-D\n\n# comment\nmaven.repo.local=after blank", "after blank"), ("3.9.3", "-D maven.repo.local=trimmed", "trimmed"), ("3.9.3", "-Dmaven.repo.local=${session.rootDirectory}/root", "root"), ("3.9.3", "-Dbase=based\n-Dmaven.repo.local=${base}/repo", "based/repo"), @@ -925,16 +926,23 @@ def test_maven_install_local_reads_maven_config_like_maven( assert _jar(maven_project / repository).exists() +@pytest.mark.parametrize( + "wrapper_properties", + [ + "distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven" + "/apache-maven/3.8.8/apache-maven-3.8.8-bin.zip\n", + "distributionUrl=https://mirror.example/apache-maven-3.10.0-bin.zip\n" + " distributionUrl : https://mirror.example/apache-maven-3.8.8-bin.zip\n", + ], +) def test_maven_install_local_splits_maven_config_for_the_maven_wrapper( - home, fake_repository, maven_project + wrapper_properties, home, fake_repository, maven_project ): """The Maven of .mvn/wrapper, not the mvn on PATH, decides how maven.config is split.""" wrapper_dir = maven_project / ".mvn" / "wrapper" wrapper_dir.mkdir(parents=True) (wrapper_dir / "maven-wrapper.properties").write_text( - "distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven" - "/apache-maven/3.8.8/apache-maven-3.8.8-bin.zip\n", - encoding="utf-8", + wrapper_properties, encoding="utf-8" ) (maven_project / ".mvn" / "maven.config").write_text( "-Dmaven.repo.local=m2 -B\n", encoding="utf-8" diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 5e958a85..175642c2 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -45,9 +45,11 @@ _WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') _JAVA_SPLIT_ARGUMENT = re.compile(r"\S+", re.ASCII) _PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") -_WRAPPER_MAVEN_VERSION = re.compile( - r"^distributionUrl\s*[=:].*/apache-maven/([\w.-]+)/", re.MULTILINE +_JAVA_LINE_BREAK = re.compile(r"\r\n|\r|\n") +_WRAPPER_DISTRIBUTION_URL = re.compile( + r"^[ \t\f]*distributionUrl(?:[ \t\f]*[=:][ \t\f]*|[ \t\f]+)(.*)$", re.MULTILINE ) +_MAVEN_DISTRIBUTION_VERSION = re.compile(r"apache-maven-(\d+\.\d+[\w.-]*?)-bin\.") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -123,7 +125,8 @@ def _properties(definitions: list[str]) -> dict[str, str]: def _wrapper_maven_version(mvn_dir: Path) -> str | None: """Return the Maven version that ``mvnw`` downloads, or ``None`` without a wrapper.""" properties = _read(mvn_dir / "wrapper" / "maven-wrapper.properties") - match = _WRAPPER_MAVEN_VERSION.search(properties) if properties else None + urls = _WRAPPER_DISTRIBUTION_URL.findall(properties) if properties else [] + match = _MAVEN_DISTRIBUTION_VERSION.search(urls[-1]) if urls else None return match.group(1) if match else None @@ -140,7 +143,11 @@ def _maven_config_repo_local( maven_config = _read(maven_config_file) if not maven_config: return None - arguments = maven_config.splitlines() + arguments = [ + line + for line in _JAVA_LINE_BREAK.split(maven_config) + if line and not line.startswith("#") + ] version = _wrapper_maven_version(mvn_dir) or _maven_version() if version and _major_minor(version) < FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE: arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) From 8361a1b6ebda453a52f5392ba2b9c48f4d14e669 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:18:51 +0000 Subject: [PATCH 14/34] fix: refuse --mavenrc when the JAR path has a class path separator Java splits maven.ext.class.path at the path separator, so a JAR path with one cannot load. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../cli/tests/test_credential_helper_maven_tools.py | 8 +++++--- cloudsmith_cli/credential_helpers/maven/installer.py | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49108e81..f15ddd44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added `cloudsmith credential-helper install maven`, `install gradle`, and `install sbt`. They set up the Cloudsmith Maven tools, which get repository credentials from `cloudsmith credential-helper generic`. Each command installs the newest release from the public `cloudsmith/maven-tools` repository, for every build of the user. - - `maven` puts the extension in `~/.m2/repository` and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space. In these cases the command stops and tells you to use `--local` or Maven 3.10. + - `maven` puts the extension in `~/.m2/repository` and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space or a `:`. In these cases the command stops and tells you to use `--local` or Maven 3.10. - `gradle` writes an init script to `~/.gradle/init.d` (or `$GRADLE_USER_HOME/init.d`), which applies the plugin to every Gradle build. - `sbt` writes a global plugin to `~/.sbt/1.0/plugins`, which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index db7afe5c..fd753023 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -864,11 +864,13 @@ def test_maven_install_with_mavenrc_adds_a_block( assert not _user_extensions(home).exists() -@pytest.mark.parametrize(("separator", "refused"), [(" ", True), ("\u00a0", False)]) -def test_maven_install_with_mavenrc_refuses_a_jar_path_with_a_space( +@pytest.mark.parametrize( + ("separator", "refused"), [(" ", True), (os.pathsep, True), ("\u00a0", False)] +) +def test_maven_install_with_mavenrc_refuses_a_jar_path_that_mvn_would_split( separator, refused, tmp_path, monkeypatch, fake_repository, outside_projects ): - """mvn splits MAVEN_OPTS at spaces, so the JAR path would break every build.""" + """mvn splits MAVEN_OPTS at spaces and Java splits the class path, so the JAR path would break every build.""" home = tmp_path / f"John{separator}Smith" home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: home)) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 596668ab..fc3c741c 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -14,6 +14,7 @@ from __future__ import annotations import contextlib +import os import re import shutil import subprocess @@ -199,10 +200,11 @@ def install( ) version = maven_tools.latest_version(EXTENSION_PATH, session) jar = _version_dir(version) / f"{extensions.ARTIFACT_ID}-{version}.jar" - if mavenrc and any(char in " \t\n" for char in str(jar)): + if mavenrc and any(char in f" \t\n{os.pathsep}" for char in str(jar)): raise MavenrcNotSupported( - "The mvn script splits MAVEN_OPTS at spaces, so it cannot load " - f"{jar}. {INSTEAD_OF_MAVENRC}" + "The mvn script splits MAVEN_OPTS at spaces and Java splits the " + f"class path at {os.pathsep}, so they cannot load {jar}. " + f"{INSTEAD_OF_MAVENRC}" ) actions = self._download(version, dry_run, session) if mavenrc: From 2c93eb6e4ab3280642c5787d5cba931b4e7032f8 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:19:46 +0000 Subject: [PATCH 15/34] test: ignore the MAVEN_OPTS of the user in every Maven test Co-Authored-By: Claude Opus 5.5 --- .../cli/tests/test_credential_helper_maven_tools.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 046abbe4..96cf4471 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -72,7 +72,6 @@ def home(tmp_path, monkeypatch): home_dir.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir)) monkeypatch.delenv("GRADLE_USER_HOME", raising=False) - monkeypatch.delenv("MAVEN_OPTS", raising=False) return home_dir @@ -93,6 +92,12 @@ def fake_download(artifact_path, destination, session=None): return downloads +@pytest.fixture(autouse=True) +def _no_maven_opts(monkeypatch): + """Ignore the MAVEN_OPTS of the user who runs the tests.""" + monkeypatch.delenv("MAVEN_OPTS", raising=False) + + @pytest.fixture(autouse=True) def _maven_3_10(monkeypatch): """Run as if `mvn -v` reports Maven 3.10.0.""" From b45c8c48c9ee99f0642b5227841170bdf4e81c2d Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:33:43 +0000 Subject: [PATCH 16/34] fix: keep a bare or empty maven.repo.local like Maven A -D without = sets true in Maven and an empty value in Java. An empty maven.repo.local is the current directory, not an unset option. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 2 ++ .../credential_helpers/maven/installer.py | 19 ++++++++++++------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 96cf4471..5228a98b 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -895,6 +895,8 @@ def test_maven_install_local_reads_maven_repo_local_of_the_project( ("3.9.3", "-D\nmaven.repo.local=next line", "next line"), ("3.9.3", "-D\n\n# comment\nmaven.repo.local=after blank", "after blank"), ("3.9.3", "-D maven.repo.local=trimmed", "trimmed"), + ("3.9.3", "-Dmaven.repo.local=first\n-Dmaven.repo.local", "true"), + ("3.9.3", "-Dmaven.repo.local=", "."), ("3.9.3", "-Dmaven.repo.local=${session.rootDirectory}/root", "root"), ("3.9.3", "-Dbase=based\n-Dmaven.repo.local=${base}/repo", "based/repo"), ( diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index fe2b95a6..b6b9ff9c 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -112,13 +112,15 @@ def _maven_definitions(arguments: list[str]) -> list[str]: return definitions -def _properties(definitions: list[str]) -> dict[str, str]: - """Return the properties of ``name=value`` definitions; Maven trims each name.""" +def _properties(definitions: list[str], bare_value: str) -> dict[str, str]: + """Return the properties of ``name=value`` definitions; Maven trims each name. + + A definition without ``=`` gets ``bare_value``: ``true`` from Maven, empty from Java. + """ properties = {} for definition in definitions: name, separator, value = definition.partition("=") - if separator: - properties[name.strip()] = value + properties[name.strip()] = value if separator else bare_value return properties @@ -151,7 +153,7 @@ def _maven_config_repo_local( version = _wrapper_maven_version(mvn_dir) or _maven_version() if version and _major_minor(version) < FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE: arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) - defined = _properties(_maven_definitions(arguments)) + defined = _properties(_maven_definitions(arguments), bare_value="true") if _REPO_LOCAL_PROPERTY not in defined: return None root = str(mvn_dir.parent) @@ -185,7 +187,8 @@ def _repo_local_option(local: bool) -> str | None: argument.removeprefix("-D") for argument in _jvm_arguments(jvm_options) if argument.startswith("-D") - ] + ], + bare_value="", ) if mvn_dir: maven_config_value = _maven_config_repo_local(mvn_dir, jvm_properties) @@ -255,7 +258,9 @@ def _settings_local_repository() -> str | None: def _local_repository(local: bool) -> Path: """Return the local repository that Maven uses, in the same order of precedence.""" - location = _repo_local_option(local) or _settings_local_repository() + location = _repo_local_option(local) + if location is None: + location = _settings_local_repository() if location is None: return Path.home() / ".m2" / "repository" return Path(location).absolute() From 41ddfa17a5581c9d204f991bb332a73d83667484 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 16:54:23 +0000 Subject: [PATCH 17/34] fix: report a settings.xml with an unknown encoding as a config error Co-Authored-By: Claude Opus 5.5 --- .../tests/test_credential_helper_maven_tools.py | 14 ++++++++++++++ .../credential_helpers/maven/installer.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 5228a98b..66daff45 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -977,6 +977,20 @@ def test_maven_install_with_unusable_settings_fails( assert not fake_repository +def test_maven_install_with_an_unknown_settings_encoding_fails( + home, fake_repository, outside_projects +): + """A settings file with an encoding that Python does not know stops the install.""" + settings = home / ".m2" / "settings.xml" + settings.parent.mkdir() + settings.write_text('\n\n') + + with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): + MavenInstaller().install() + + assert not fake_repository + + def test_maven_install_local_turns_on_the_extension_in_the_project( home, fake_repository, maven_project ): diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index b6b9ff9c..0ff55768 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -241,7 +241,7 @@ def _settings_local_repository() -> str | None: return None try: root = ElementTree.parse(settings_file).getroot() - except ElementTree.ParseError as exc: + except (ElementTree.ParseError, LookupError) as exc: raise ConfigFileParseError( f"Cannot parse {settings_file} as XML: {exc}. {_FIX_CONFIG_FILE_HINT}" ) from exc From 72bd4d123795a1384cdc1426f18775acec9d45a1 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 17:07:27 +0000 Subject: [PATCH 18/34] fix: handle multi-byte settings.xml encodings and pre-release wrapper versions Co-Authored-By: Claude Opus 5.5 --- .../cli/tests/test_credential_helper_maven_tools.py | 10 ++++++---- cloudsmith_cli/credential_helpers/maven/installer.py | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 66daff45..e4e5d7b0 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -940,6 +940,7 @@ def test_maven_install_local_reads_maven_config_like_maven( "/apache-maven/3.8.8/apache-maven-3.8.8-bin.zip\n", "distributionUrl=https://mirror.example/apache-maven-3.10.0-bin.zip\n" " distributionUrl : https://mirror.example/apache-maven-3.8.8-bin.zip\n", + "distributionUrl=https://mirror.example/apache-maven-3.0-beta-3-bin.zip\n", ], ) def test_maven_install_local_splits_maven_config_for_the_maven_wrapper( @@ -977,13 +978,14 @@ def test_maven_install_with_unusable_settings_fails( assert not fake_repository -def test_maven_install_with_an_unknown_settings_encoding_fails( - home, fake_repository, outside_projects +@pytest.mark.parametrize("encoding", ["unknown", "Shift_JIS"]) +def test_maven_install_with_an_unsupported_settings_encoding_fails( + encoding, home, fake_repository, outside_projects ): - """A settings file with an encoding that Python does not know stops the install.""" + """A settings file with an encoding that ElementTree cannot read stops the install.""" settings = home / ".m2" / "settings.xml" settings.parent.mkdir() - settings.write_text('\n\n') + settings.write_text(f'\n\n') with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): MavenInstaller().install() diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 0ff55768..bb1ebe66 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -49,7 +49,7 @@ _WRAPPER_DISTRIBUTION_URL = re.compile( r"^[ \t\f]*distributionUrl(?:[ \t\f]*[=:][ \t\f]*|[ \t\f]+)(.*)$", re.MULTILINE ) -_MAVEN_DISTRIBUTION_VERSION = re.compile(r"apache-maven-(\d+\.\d+[\w.-]*?)-bin\.") +_MAVEN_DISTRIBUTION_VERSION = re.compile(r"apache-maven-(\d+(?:\.\d+)+)[\w.-]*?-bin\.") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -241,7 +241,7 @@ def _settings_local_repository() -> str | None: return None try: root = ElementTree.parse(settings_file).getroot() - except (ElementTree.ParseError, LookupError) as exc: + except (ElementTree.ParseError, LookupError, ValueError) as exc: raise ConfigFileParseError( f"Cannot parse {settings_file} as XML: {exc}. {_FIX_CONFIG_FILE_HINT}" ) from exc From a3d8f368f8d6b053625f2d63d7d62a6d8b3636cb Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 17:32:57 +0000 Subject: [PATCH 19/34] refactor: ask Maven for its local repository instead of parsing its config Maven logs the local repository in its debug output before it stops on the missing goal. Read that line instead of copying how each Maven version reads maven.config, jvm.config, MAVEN_OPTS, settings.xml and the wrapper. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- .../test_credential_helper_maven_tools.py | 216 ++++------------ .../credential_helpers/maven/installer.py | 243 ++++-------------- 3 files changed, 94 insertions(+), 367 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27aa869e..a843e7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Added `cloudsmith credential-helper install maven`, `install gradle`, and `install sbt`. They set up the Cloudsmith Maven tools, which get repository credentials from `cloudsmith credential-helper generic`. Each command installs the newest release from the public `cloudsmith/maven-tools` repository, for every build of the user. - - `maven` puts the extension in the Maven local repository (`-Dmaven.repo.local`, then `` in `~/.m2/settings.xml`, then `~/.m2/repository`) and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space or a `:`. In these cases the command stops and tells you to use `--local` or Maven 3.10. + - `maven` puts the extension in the local repository that Maven reports (`~/.m2/repository` when the command cannot run `mvn`) and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space or a `:`. In these cases the command stops and tells you to use `--local` or Maven 3.10. - `gradle` writes an init script to `~/.gradle/init.d` (or `$GRADLE_USER_HOME/init.d`), which applies the plugin to every Gradle build. - `sbt` writes a global plugin to `~/.sbt/1.0/plugins`, which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index e4e5d7b0..c28d5d31 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -9,7 +9,7 @@ import errno import os -import re +import shutil import subprocess from contextlib import nullcontext from pathlib import Path @@ -20,7 +20,6 @@ import httpretty.core import pytest -from ...core.cache_utils import ConfigFileParseError from ...credential_helpers import maven_tools from ...credential_helpers.gradle.installer import GradleInstaller from ...credential_helpers.maven import extensions, mavenrc @@ -93,9 +92,16 @@ def fake_download(artifact_path, destination, session=None): @pytest.fixture(autouse=True) -def _no_maven_opts(monkeypatch): - """Ignore the MAVEN_OPTS of the user who runs the tests.""" - monkeypatch.delenv("MAVEN_OPTS", raising=False) +def maven_debug_output(monkeypatch): + """Run as if Maven reports no local repository, and record each Maven command.""" + commands = [] + + def fake_output(command, cwd=None): + commands.append((command, cwd)) + return "" + + monkeypatch.setattr(maven_installer, "_maven_output", fake_output) + return commands @pytest.fixture(autouse=True) @@ -733,18 +739,6 @@ def _user_extensions(home: Path) -> Path: return home / ".m2" / "extensions.xml" -def _write_settings(home: Path, local_repository: str) -> Path: - settings = home / ".m2" / "settings.xml" - settings.parent.mkdir() - settings.write_text( - '\n' - f" {local_repository}\n" - "\n", - encoding="utf-8", - ) - return settings - - def _jar(repository: Path) -> Path: return ( repository / EXTENSION_PATH / "0.2.0" / "cloudsmith-maven-extension-0.2.0.jar" @@ -820,177 +814,67 @@ def test_maven_install_marks_the_files_as_installed( ) -@pytest.mark.parametrize("local_repository", ["${user.home}/repo", "${env.M2_REPO}"]) -def test_maven_install_uses_the_local_repository_of_the_user_settings( - local_repository, home, fake_repository, outside_projects, monkeypatch -): - """Maven reads from ~/.m2/settings.xml, after interpolation.""" - monkeypatch.setenv("M2_REPO", str(home / "repo")) - _write_settings(home, local_repository) - - actions = MavenInstaller().install() - - assert _jar(home / "repo").exists() - assert f"downloaded {_jar(home).name} to {_jar(home / 'repo').parent}" in actions - - -def test_maven_install_prefers_maven_repo_local_in_maven_opts( +def test_maven_install_uses_the_local_repository_that_maven_reports( home, fake_repository, outside_projects, monkeypatch ): - """-Dmaven.repo.local overrides the settings, and --mavenrc loads the JAR there.""" - _write_settings(home, "${user.home}/repo") - monkeypatch.setenv("MAVEN_OPTS", f"-Xmx1g -Dmaven.repo.local={home / 'opts'}") + """The JAR goes in the repository from the debug output of Maven, and --mavenrc loads it there.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_installer, + "_maven_output", + lambda command, cwd=None: ( + "[DEBUG] Using manager EnhancedLocalRepositoryManager" + f" with priority 10.0 for {home / 'repo'}\n" + ), + ) MavenInstaller().install(mavenrc=True) - assert _jar(home / "opts").exists() + assert _jar(home / "repo").exists() text = (home / ".mavenrc").read_text(encoding="utf-8") - assert f"-Dmaven.ext.class.path={_jar(home / 'opts')}" in text + assert f"-Dmaven.ext.class.path={_jar(home / 'repo')}" in text @pytest.mark.parametrize( - ("windows", "quote", "directory"), - [(False, "", "no break"), (True, '"', "my repo")], + ("local", "wrapper", "runs_in_project"), + [(False, True, False), (True, False, True), (True, True, True)], ) -def test_maven_install_splits_maven_opts_like_the_mvn_script( - windows, quote, directory, home, fake_repository, outside_projects, monkeypatch -): - """mvn splits MAVEN_OPTS only at spaces, tabs and new lines; mvn.cmd keeps quoted text together.""" - monkeypatch.setattr(maven_installer, "is_windows", lambda: windows) - repository = home / directory - monkeypatch.setenv( - "MAVEN_OPTS", f"{quote}-Dmaven.repo.local={repository}{quote} -B" - ) - - MavenInstaller().install() - - assert _jar(repository).exists() - - -@pytest.mark.parametrize( - ("config_file", "maven_opts"), - [("jvm.config", ""), ("maven.config", "-Dmaven.repo.local=/elsewhere")], -) -def test_maven_install_local_reads_maven_repo_local_of_the_project( - config_file, maven_opts, home, fake_repository, maven_project, monkeypatch -): - """With local, the .mvn config files of the project can set the local repository.""" - monkeypatch.setenv("MAVEN_OPTS", maven_opts) - (maven_project / ".mvn").mkdir() - (maven_project / ".mvn" / config_file).write_text( - f"-Dmaven.repo.local={maven_project / 'repo'}\n", encoding="utf-8" - ) - - MavenInstaller().install(local=True) - - assert _jar(maven_project / "repo").exists() - - -@pytest.mark.parametrize( - ("maven_version", "maven_config", "repository"), - [ - ("3.9.3", "# -Dmaven.repo.local=old-repo", "opts"), - ("3.9.3", "-Dmaven.repo.local=my repo", "my repo"), - ("3.9.3", "--define=maven.repo.local=defined", "defined"), - ("3.9.3", "-D\nmaven.repo.local=next line", "next line"), - ("3.9.3", "-D\n\n# comment\nmaven.repo.local=after blank", "after blank"), - ("3.9.3", "-D maven.repo.local=trimmed", "trimmed"), - ("3.9.3", "-Dmaven.repo.local=first\n-Dmaven.repo.local", "true"), - ("3.9.3", "-Dmaven.repo.local=", "."), - ("3.9.3", "-Dmaven.repo.local=${session.rootDirectory}/root", "root"), - ("3.9.3", "-Dbase=based\n-Dmaven.repo.local=${base}/repo", "based/repo"), - ( - "3.9.3", - "-Dbase=${session.rootDirectory}/cache\n-Dmaven.repo.local=${base}/repo", - "cache/repo", - ), - ("3.9.3", "-Dmaven.repo.local=${repo.base}/m2", "based/m2"), - ("3.8.8", "-Dmaven.repo.local=m2 -B", "m2"), - ], -) -def test_maven_install_local_reads_maven_config_like_maven( - maven_version, - maven_config, - repository, +def test_maven_install_asks_the_maven_that_the_build_uses( + local, + wrapper, + runs_in_project, home, fake_repository, maven_project, + maven_debug_output, monkeypatch, ): - """Maven 3.9 reads each .mvn/maven.config line as one argument; 3.8 splits on whitespace.""" - monkeypatch.setattr(maven_installer, "_maven_version", lambda: maven_version) - monkeypatch.setenv( - "MAVEN_OPTS", - f"-Drepo.base=based -Dmaven.repo.local={maven_project / 'opts'}", - ) - (maven_project / ".mvn").mkdir() - (maven_project / ".mvn" / "maven.config").write_text( - f"{maven_config}\n", encoding="utf-8" - ) + """With local, the Maven wrapper of the project runs in the project; else mvn runs outside it.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + mvnw = maven_project / "mvnw" + if wrapper: + mvnw.write_text("", encoding="utf-8") - MavenInstaller().install(local=True) + MavenInstaller().install(local=local) - assert _jar(maven_project / repository).exists() + [(command, cwd)] = maven_debug_output + executable = str(mvnw) if local and wrapper else "/bin/mvn" + assert command == [executable, "-X", "-o", "-B"] + assert (cwd == maven_project) is runs_in_project -@pytest.mark.parametrize( - "wrapper_properties", - [ - "distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven" - "/apache-maven/3.8.8/apache-maven-3.8.8-bin.zip\n", - "distributionUrl=https://mirror.example/apache-maven-3.10.0-bin.zip\n" - " distributionUrl : https://mirror.example/apache-maven-3.8.8-bin.zip\n", - "distributionUrl=https://mirror.example/apache-maven-3.0-beta-3-bin.zip\n", - ], -) -def test_maven_install_local_splits_maven_config_for_the_maven_wrapper( - wrapper_properties, home, fake_repository, maven_project +@pytest.mark.skipif(shutil.which("mvn") is None, reason="Maven is not installed") +# The parameter replaces the autouse fake, so the real mvn runs. +@pytest.mark.parametrize("maven_debug_output", [None]) +def test_maven_install_uses_the_local_repository_of_the_real_maven( + maven_debug_output, home, fake_repository, outside_projects, tmp_path, monkeypatch ): - """The Maven of .mvn/wrapper, not the mvn on PATH, decides how maven.config is split.""" - wrapper_dir = maven_project / ".mvn" / "wrapper" - wrapper_dir.mkdir(parents=True) - (wrapper_dir / "maven-wrapper.properties").write_text( - wrapper_properties, encoding="utf-8" - ) - (maven_project / ".mvn" / "maven.config").write_text( - "-Dmaven.repo.local=m2 -B\n", encoding="utf-8" - ) + """The real mvn reports the repository in a line that the installer finds.""" + monkeypatch.setenv("MAVEN_OPTS", f"-Dmaven.repo.local={tmp_path / 'repo'}") - MavenInstaller().install(local=True) - - assert _jar(maven_project / "m2").exists() - - -@pytest.mark.parametrize( - "local_repository", ["${env.NOT_SET}", "${env.CYCLE}", ""] -) -def test_maven_install_with_unusable_settings_fails( - local_repository, home, fake_repository, outside_projects, monkeypatch -): - """A settings file that does not parse or does not resolve stops the install.""" - monkeypatch.delenv("NOT_SET", raising=False) - monkeypatch.setenv("CYCLE", "${env.CYCLE}") - settings = _write_settings(home, local_repository) - - with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): - MavenInstaller().install() - - assert not fake_repository - - -@pytest.mark.parametrize("encoding", ["unknown", "Shift_JIS"]) -def test_maven_install_with_an_unsupported_settings_encoding_fails( - encoding, home, fake_repository, outside_projects -): - """A settings file with an encoding that ElementTree cannot read stops the install.""" - settings = home / ".m2" / "settings.xml" - settings.parent.mkdir() - settings.write_text(f'\n\n') - - with pytest.raises(ConfigFileParseError, match=re.escape(str(settings))): - MavenInstaller().install() + MavenInstaller().install() - assert not fake_repository + assert _jar(tmp_path / "repo").exists() def test_maven_install_local_turns_on_the_extension_in_the_project( diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index bb1ebe66..4c63716e 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -18,12 +18,11 @@ import re import shutil import subprocess +import tempfile from functools import partial from pathlib import Path from typing import TYPE_CHECKING -from xml.etree import ElementTree -from ...core.cache_utils import _FIX_CONFIG_FILE_HINT, ConfigFileParseError from .. import maven_tools from ..launchers import is_windows from ..maven_tools import ProjectNotFound @@ -37,19 +36,10 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) -FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE = (3, 9) MAVEN_VERSION_TIMEOUT_SECONDS = 60 -_REPO_LOCAL_PROPERTY = "maven.repo.local" -_MAVEN_DEFINE_OPTIONS = ("-D", "--define") -_POSIX_JVM_ARGUMENT = re.compile(r"[^ \t\r\n]+") -_WINDOWS_JVM_ARGUMENT = re.compile(r'(?:[^ \t\r\n"]|"[^"]*"?)+') -_JAVA_SPLIT_ARGUMENT = re.compile(r"\S+", re.ASCII) -_PLACEHOLDER = re.compile(r"\$\{([^}]*)\}") -_JAVA_LINE_BREAK = re.compile(r"\r\n|\r|\n") -_WRAPPER_DISTRIBUTION_URL = re.compile( - r"^[ \t\f]*distributionUrl(?:[ \t\f]*[=:][ \t\f]*|[ \t\f]+)(.*)$", re.MULTILINE +_LOCAL_REPOSITORY_LOG = re.compile( + r"^\[DEBUG\] Using manager \S+ with priority \S+ for (.+)$", re.MULTILINE ) -_MAVEN_DISTRIBUTION_VERSION = re.compile(r"apache-maven-(\d+(?:\.\d+)+)[\w.-]*?-bin\.") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -89,183 +79,6 @@ def _mavenrc_file() -> Path: return Path.home() / ".mavenrc" -def _jvm_arguments(options: str) -> list[str]: - """Split JVM options at spaces, tabs and new lines; ``mvn.cmd`` keeps quoted text together.""" - if is_windows(): - return [ - argument.replace('"', "") - for argument in _WINDOWS_JVM_ARGUMENT.findall(options) - ] - return _POSIX_JVM_ARGUMENT.findall(options) - - -def _maven_definitions(arguments: list[str]) -> list[str]: - """Return the ``name=value`` of each ``-D`` or ``--define`` option in Maven arguments.""" - definitions = [] - for previous, argument in zip(["", *arguments], arguments): - if previous in _MAVEN_DEFINE_OPTIONS: - definitions.append(argument) - elif argument.startswith("--define="): - definitions.append(argument.removeprefix("--define=")) - elif argument.startswith("-D") and argument != "-D": - definitions.append(argument.removeprefix("-D")) - return definitions - - -def _properties(definitions: list[str], bare_value: str) -> dict[str, str]: - """Return the properties of ``name=value`` definitions; Maven trims each name. - - A definition without ``=`` gets ``bare_value``: ``true`` from Maven, empty from Java. - """ - properties = {} - for definition in definitions: - name, separator, value = definition.partition("=") - properties[name.strip()] = value if separator else bare_value - return properties - - -def _wrapper_maven_version(mvn_dir: Path) -> str | None: - """Return the Maven version that ``mvnw`` downloads, or ``None`` without a wrapper.""" - properties = _read(mvn_dir / "wrapper" / "maven-wrapper.properties") - urls = _WRAPPER_DISTRIBUTION_URL.findall(properties) if properties else [] - match = _MAVEN_DISTRIBUTION_VERSION.search(urls[-1]) if urls else None - return match.group(1) if match else None - - -def _maven_config_repo_local( - mvn_dir: Path, jvm_properties: dict[str, str] -) -> str | None: - """Return ``maven.repo.local`` of ``.mvn/maven.config`` with its placeholders resolved. - - Each line of ``.mvn/maven.config`` is one argument, and Maven skips the lines - that start with ``#``. Maven before 3.9 splits the file on whitespace instead. - The Maven of ``mvnw`` decides, else the ``mvn`` on ``PATH``. - """ - maven_config_file = mvn_dir / "maven.config" - maven_config = _read(maven_config_file) - if not maven_config: - return None - arguments = [ - line - for line in _JAVA_LINE_BREAK.split(maven_config) - if line and not line.startswith("#") - ] - version = _wrapper_maven_version(mvn_dir) or _maven_version() - if version and _major_minor(version) < FIRST_MAVEN_WITH_ONE_ARGUMENT_PER_LINE: - arguments = _JAVA_SPLIT_ARGUMENT.findall(maven_config) - defined = _properties(_maven_definitions(arguments), bare_value="true") - if _REPO_LOCAL_PROPERTY not in defined: - return None - root = str(mvn_dir.parent) - properties = { - "user.home": str(Path.home()), - "maven.multiModuleProjectDirectory": root, - **jvm_properties, - **defined, - "session.rootDirectory": root, - "session.topDirectory": str(maven_tools.current_dir()), - } - return _interpolate( - defined[_REPO_LOCAL_PROPERTY], - f"{_REPO_LOCAL_PROPERTY} of {maven_config_file}", - properties, - ) - - -def _repo_local_option(local: bool) -> str | None: - """Return the ``maven.repo.local`` value that Maven uses. - - ``.mvn/maven.config`` overrides the JVM options. ``mvn`` puts - ``.mvn/jvm.config`` before ``MAVEN_OPTS``, and Java uses the last value. - """ - jvm_options = os.environ.get("MAVEN_OPTS", "") - mvn_dir = _project_dir() / ".mvn" if local else None - if mvn_dir: - jvm_options = f"{_read(mvn_dir / 'jvm.config') or ''} {jvm_options}" - jvm_properties = _properties( - [ - argument.removeprefix("-D") - for argument in _jvm_arguments(jvm_options) - if argument.startswith("-D") - ], - bare_value="", - ) - if mvn_dir: - maven_config_value = _maven_config_repo_local(mvn_dir, jvm_properties) - if maven_config_value is not None: - return maven_config_value - return jvm_properties.get(_REPO_LOCAL_PROPERTY) - - -def _resolve_placeholder( - match: re.Match[str], where: str, properties: dict[str, str] -) -> str: - name = match.group(1) - env_name = name.removeprefix("env.") - if name in properties: - return properties[name] - if name.startswith("env.") and env_name in os.environ: - return os.environ[env_name] - raise ConfigFileParseError( - f"Cannot resolve {match.group(0)} in {where}. {_FIX_CONFIG_FILE_HINT}" - ) - - -def _interpolate( - value: str, - where: str, - properties: dict[str, str], - resolving: frozenset[str] = frozenset(), -) -> str: - """Replace each ``${name}`` in ``value``, and then the placeholders in its value, as Maven does.""" - - def replace(match: re.Match[str]) -> str: - if match.group(1) in resolving: - raise ConfigFileParseError( - f"{match.group(0)} refers to itself in {where}. {_FIX_CONFIG_FILE_HINT}" - ) - return _interpolate( - _resolve_placeholder(match, where, properties), - where, - properties, - resolving | {match.group(1)}, - ) - - return _PLACEHOLDER.sub(replace, value) - - -def _settings_local_repository() -> str | None: - """Return ```` of ``~/.m2/settings.xml``, or ``None`` when it has none.""" - settings_file = Path.home() / ".m2" / "settings.xml" - if not settings_file.exists(): - return None - try: - root = ElementTree.parse(settings_file).getroot() - except (ElementTree.ParseError, LookupError, ValueError) as exc: - raise ConfigFileParseError( - f"Cannot parse {settings_file} as XML: {exc}. {_FIX_CONFIG_FILE_HINT}" - ) from exc - # {*} matches the namespace of the Maven settings, or no namespace. - value = root.findtext("{*}localRepository", "").strip() - if not value: - return None - return _interpolate( - value, - f" of {settings_file}", - {"user.home": str(Path.home())}, - ) - - -def _local_repository(local: bool) -> Path: - """Return the local repository that Maven uses, in the same order of precedence.""" - location = _repo_local_option(local) - if location is None: - location = _settings_local_repository() - if location is None: - return Path.home() / ".m2" / "repository" - return Path(location).absolute() - - def _version_dir(repository: Path, version: str) -> Path: return repository / EXTENSION_PATH / version @@ -277,34 +90,64 @@ def _read(path: Path) -> str | None: return path.read_text(encoding="utf-8", errors="surrogateescape") -def _maven_version() -> str | None: - """Return the version that ``mvn -v`` reports, or ``None`` when there is none.""" - mvn = shutil.which("mvn") - if not mvn: - return None +def _maven_output(command: list[str], cwd: Path | None = None) -> str: + """Return what the Maven command prints, or an empty string when it does not run.""" try: result = subprocess.run( - [mvn, "-v"], + command, + cwd=cwd, capture_output=True, text=True, timeout=MAVEN_VERSION_TIMEOUT_SECONDS, check=False, ) except (OSError, subprocess.TimeoutExpired): + return "" + return result.stdout + + +def _maven_version() -> str | None: + """Return the version that ``mvn -v`` reports, or ``None`` when there is none.""" + mvn = shutil.which("mvn") + if not mvn: return None - match = re.search(r"Apache Maven (\d+(?:\.\d+)+)", result.stdout) + match = re.search(r"Apache Maven (\d+(?:\.\d+)+)", _maven_output([mvn, "-v"])) return match.group(1) if match else None -def _major_minor(version: str) -> tuple[int, ...]: - return tuple(int(part) for part in version.split(".")[:2]) +def _maven_executable(local: bool) -> str | None: + """Return the Maven wrapper of the project with ``local``, else ``mvn`` on ``PATH``.""" + if local: + wrapper = _project_dir() / ("mvnw.cmd" if is_windows() else "mvnw") + if wrapper.is_file(): + return str(wrapper) + return shutil.which("mvn") + + +def _local_repository(local: bool) -> Path: + """Return the local repository that Maven reports, or ``~/.m2/repository``. + + Maven logs the repository before it stops on the missing goal, so no plugin runs. + Without ``local``, Maven runs in an empty directory to skip the project ``.mvn``. + """ + mvn = _maven_executable(local) + output = "" + if mvn: + with tempfile.TemporaryDirectory() as empty_dir: + cwd = _project_dir() if local else Path(empty_dir) + output = _maven_output([mvn, "-X", "-o", "-B"], cwd) + match = _LOCAL_REPOSITORY_LOG.search(output) + if match is None: + return Path.home() / ".m2" / "repository" + return Path(match.group(1)) def _old_maven_warning() -> list[str]: version = _maven_version() if version is None: return [] - if _major_minor(version) >= FIRST_MAVEN_WITH_USER_EXTENSIONS: + major_minor = tuple(int(part) for part in version.split(".")[:2]) + if major_minor >= FIRST_MAVEN_WITH_USER_EXTENSIONS: return [] warning = ( f"WARNING: Maven {version} ignores {_user_extensions_file()}. " From 71ec7899e56f184b96ac40cc6199532d45b07fdc Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 17:42:28 +0000 Subject: [PATCH 20/34] fix: stop the Maven probe before any goal of the build runs A POM default goal or goals in MAVEN_ARGS ran when the probe gave no goal. An unknown phase makes Maven stop before it runs a plugin. Read stderr too, and run the real-Maven test on Windows. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 19 +++++++++++++++---- .../credential_helpers/maven/installer.py | 9 ++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index c28d5d31..390446bb 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -849,7 +849,7 @@ def test_maven_install_asks_the_maven_that_the_build_uses( maven_debug_output, monkeypatch, ): - """With local, the Maven wrapper of the project runs in the project; else mvn runs outside it.""" + """With local, the Maven wrapper runs in the project; else mvn runs outside it. No goal of the build runs.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") mvnw = maven_project / "mvnw" if wrapper: @@ -859,18 +859,29 @@ def test_maven_install_asks_the_maven_that_the_build_uses( [(command, cwd)] = maven_debug_output executable = str(mvnw) if local and wrapper else "/bin/mvn" - assert command == [executable, "-X", "-o", "-B"] + assert command == [ + executable, + "-X", + "-o", + "-B", + "cloudsmith-local-repository-probe", + ] assert (cwd == maven_project) is runs_in_project +@pytest.mark.windows @pytest.mark.skipif(shutil.which("mvn") is None, reason="Maven is not installed") # The parameter replaces the autouse fake, so the real mvn runs. @pytest.mark.parametrize("maven_debug_output", [None]) def test_maven_install_uses_the_local_repository_of_the_real_maven( maven_debug_output, home, fake_repository, outside_projects, tmp_path, monkeypatch ): - """The real mvn reports the repository in a line that the installer finds.""" - monkeypatch.setenv("MAVEN_OPTS", f"-Dmaven.repo.local={tmp_path / 'repo'}") + """The real mvn reports the repository in a line that the installer finds, on stderr too.""" + monkeypatch.setenv( + "MAVEN_OPTS", + f"-Dmaven.repo.local={tmp_path / 'repo'}" + " -Dorg.slf4j.simpleLogger.logFile=System.err", + ) MavenInstaller().install() diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 4c63716e..6859e41c 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -40,6 +40,7 @@ _LOCAL_REPOSITORY_LOG = re.compile( r"^\[DEBUG\] Using manager \S+ with priority \S+ for (.+)$", re.MULTILINE ) +_UNKNOWN_PHASE = "cloudsmith-local-repository-probe" INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -96,7 +97,8 @@ def _maven_output(command: list[str], cwd: Path | None = None) -> str: result = subprocess.run( command, cwd=cwd, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=MAVEN_VERSION_TIMEOUT_SECONDS, check=False, @@ -127,7 +129,8 @@ def _maven_executable(local: bool) -> str | None: def _local_repository(local: bool) -> Path: """Return the local repository that Maven reports, or ``~/.m2/repository``. - Maven logs the repository before it stops on the missing goal, so no plugin runs. + Maven logs the repository, then stops on a phase that does not exist before it + runs a plugin, also for a default goal or goals in ``MAVEN_ARGS``. Without ``local``, Maven runs in an empty directory to skip the project ``.mvn``. """ mvn = _maven_executable(local) @@ -135,7 +138,7 @@ def _local_repository(local: bool) -> Path: if mvn: with tempfile.TemporaryDirectory() as empty_dir: cwd = _project_dir() if local else Path(empty_dir) - output = _maven_output([mvn, "-X", "-o", "-B"], cwd) + output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) match = _LOCAL_REPOSITORY_LOG.search(output) if match is None: return Path.home() / ".m2" / "repository" From c038eeb858920bd9945980b17fefdabfcc4db1bc Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 17:48:22 +0000 Subject: [PATCH 21/34] fix: find the Maven repository line when MAVEN_ARGS turns on color Co-Authored-By: Claude Opus 5.5 --- .../cli/tests/test_credential_helper_maven_tools.py | 7 ++++--- cloudsmith_cli/credential_helpers/maven/installer.py | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 390446bb..c41cb711 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -814,16 +814,17 @@ def test_maven_install_marks_the_files_as_installed( ) +@pytest.mark.parametrize("level", ["[DEBUG]", "[\x1b[1;36mDEBUG\x1b[m]"]) def test_maven_install_uses_the_local_repository_that_maven_reports( - home, fake_repository, outside_projects, monkeypatch + level, home, fake_repository, outside_projects, monkeypatch ): - """The JAR goes in the repository from the debug output of Maven, and --mavenrc loads it there.""" + """The JAR goes in the repository from the debug output of Maven, in color too, and --mavenrc loads it there.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") monkeypatch.setattr( maven_installer, "_maven_output", lambda command, cwd=None: ( - "[DEBUG] Using manager EnhancedLocalRepositoryManager" + f"{level} Using manager EnhancedLocalRepositoryManager" f" with priority 10.0 for {home / 'repo'}\n" ), ) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 6859e41c..4706ce6a 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -41,6 +41,7 @@ r"^\[DEBUG\] Using manager \S+ with priority \S+ for (.+)$", re.MULTILINE ) _UNKNOWN_PHASE = "cloudsmith-local-repository-probe" +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -139,7 +140,7 @@ def _local_repository(local: bool) -> Path: with tempfile.TemporaryDirectory() as empty_dir: cwd = _project_dir() if local else Path(empty_dir) output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) - match = _LOCAL_REPOSITORY_LOG.search(output) + match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) if match is None: return Path.home() / ".m2" / "repository" return Path(match.group(1)) From 1df1fdd0f8132d45b4e2ac5f8cbe7166e27c0dca Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 17:55:17 +0000 Subject: [PATCH 22/34] fix: run the Maven probe where the user runs the command Maven resolves a relative local repository against the directory it runs in, so with --local the probe runs there and not in the project root. Co-Authored-By: Claude Opus 5.5 --- .../cli/tests/test_credential_helper_maven_tools.py | 13 ++++++++----- .../credential_helpers/maven/installer.py | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index c41cb711..37dba8e0 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -837,24 +837,27 @@ def test_maven_install_uses_the_local_repository_that_maven_reports( @pytest.mark.parametrize( - ("local", "wrapper", "runs_in_project"), - [(False, True, False), (True, False, True), (True, True, True)], + ("local", "wrapper"), + [(False, True), (True, False), (True, True)], ) def test_maven_install_asks_the_maven_that_the_build_uses( local, wrapper, - runs_in_project, home, fake_repository, maven_project, maven_debug_output, monkeypatch, ): - """With local, the Maven wrapper runs in the project; else mvn runs outside it. No goal of the build runs.""" + """With local, the Maven wrapper runs in the current module; else mvn runs outside the project. No goal runs.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + (maven_project / ".mvn").mkdir() mvnw = maven_project / "mvnw" if wrapper: mvnw.write_text("", encoding="utf-8") + module = maven_project / "module" + module.mkdir() + monkeypatch.chdir(module) MavenInstaller().install(local=local) @@ -867,7 +870,7 @@ def test_maven_install_asks_the_maven_that_the_build_uses( "-B", "cloudsmith-local-repository-probe", ] - assert (cwd == maven_project) is runs_in_project + assert (cwd == module) is local @pytest.mark.windows diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 4706ce6a..fe21f761 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -132,13 +132,14 @@ def _local_repository(local: bool) -> Path: Maven logs the repository, then stops on a phase that does not exist before it runs a plugin, also for a default goal or goals in ``MAVEN_ARGS``. + With ``local``, Maven runs in the current directory, as a build from there does. Without ``local``, Maven runs in an empty directory to skip the project ``.mvn``. """ mvn = _maven_executable(local) output = "" if mvn: with tempfile.TemporaryDirectory() as empty_dir: - cwd = _project_dir() if local else Path(empty_dir) + cwd = maven_tools.current_dir() if local else Path(empty_dir) output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) if match is None: From 25e210ff8aeae3e791d48fe67a80a46ead51cd63 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:01:11 +0000 Subject: [PATCH 23/34] fix: stop a user-wide Maven install when the local repository is relative Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 2 ++ .../test_credential_helper_maven_tools.py | 26 +++++++++++++++- .../credential_helpers/maven/installer.py | 30 ++++++++++++++----- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 8459ac51..8af8085f 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -23,6 +23,7 @@ from cloudsmith_cli.credential_helpers.maven.installer import ( MavenInstaller, MavenrcNotSupported, + RelativeLocalRepository, ) from cloudsmith_cli.credential_helpers.maven_tools import ( ConfigFileConflict, @@ -451,6 +452,7 @@ def install_cmd( ConfigFileConflict, UnsafeLauncherValue, MavenrcNotSupported, + RelativeLocalRepository, ) as exc: raise click.ClickException(str(exc)) except OSError as exc: diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 37dba8e0..fc5724a6 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -24,7 +24,11 @@ from ...credential_helpers.gradle.installer import GradleInstaller from ...credential_helpers.maven import extensions, mavenrc from ...credential_helpers.maven import installer as maven_installer -from ...credential_helpers.maven.installer import MavenInstaller, MavenrcNotSupported +from ...credential_helpers.maven.installer import ( + MavenInstaller, + MavenrcNotSupported, + RelativeLocalRepository, +) from ...credential_helpers.maven_tools import ConfigFileConflict, ProjectNotFound from ...credential_helpers.sbt.installer import SbtInstaller @@ -836,6 +840,26 @@ def test_maven_install_uses_the_local_repository_that_maven_reports( assert f"-Dmaven.ext.class.path={_jar(home / 'repo')}" in text +def test_maven_install_with_a_relative_local_repository_fails( + home, fake_repository, outside_projects, monkeypatch +): + """Without local, a relative repository depends on the directory of each build, so the install stops.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_installer, + "_maven_output", + lambda command, cwd=None: ( + "[DEBUG] Using manager EnhancedLocalRepositoryManager" + f" with priority 10.0 for {cwd / 'repo'}\n" + ), + ) + + with pytest.raises(RelativeLocalRepository, match="repo"): + MavenInstaller().install() + + assert not fake_repository + + @pytest.mark.parametrize( ("local", "wrapper"), [(False, True), (True, False), (True, True)], diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index fe21f761..94e017e8 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -53,6 +53,10 @@ class MavenrcNotSupported(Exception): """Raised when the ``mvn`` script cannot load the extension from ``~/.mavenrc``.""" +class RelativeLocalRepository(Exception): + """Raised when the local repository of Maven changes with the directory of the build.""" + + def _project_dir() -> Path: """Return the directory where Maven reads ``.mvn``: the nearest one that has it.""" cwd = maven_tools.current_dir() @@ -136,15 +140,25 @@ def _local_repository(local: bool) -> Path: Without ``local``, Maven runs in an empty directory to skip the project ``.mvn``. """ mvn = _maven_executable(local) - output = "" - if mvn: - with tempfile.TemporaryDirectory() as empty_dir: - cwd = maven_tools.current_dir() if local else Path(empty_dir) - output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) - match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) - if match is None: + if not mvn: return Path.home() / ".m2" / "repository" - return Path(match.group(1)) + with tempfile.TemporaryDirectory() as empty_dir: + probe_dir = Path(empty_dir) + cwd = maven_tools.current_dir() if local else probe_dir + output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) + match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) + if match is None: + return Path.home() / ".m2" / "repository" + repository = Path(match.group(1)) + if not local and probe_dir.resolve() in repository.resolve().parents: + relative = repository.resolve().relative_to(probe_dir.resolve()) + raise RelativeLocalRepository( + f"Maven uses the relative local repository {relative}, so each build " + "looks for the extension in its own directory. Set an absolute path " + "for the local repository, or run " + "`cloudsmith credential-helper install maven --local` in each project." + ) + return repository def _old_maven_warning() -> list[str]: From aa1f36e89b78245fcb4dc6637fd7036f9e1ead0c Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:06:16 +0000 Subject: [PATCH 24/34] fix: detect any relative Maven repository by asking Maven from two directories Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 7 +-- .../credential_helpers/maven/installer.py | 52 +++++++++++-------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index fc5724a6..57e018fb 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -840,8 +840,9 @@ def test_maven_install_uses_the_local_repository_that_maven_reports( assert f"-Dmaven.ext.class.path={_jar(home / 'repo')}" in text +@pytest.mark.parametrize("relative_path", ["repo", ".", "../repo"]) def test_maven_install_with_a_relative_local_repository_fails( - home, fake_repository, outside_projects, monkeypatch + relative_path, home, fake_repository, outside_projects, monkeypatch ): """Without local, a relative repository depends on the directory of each build, so the install stops.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") @@ -850,11 +851,11 @@ def test_maven_install_with_a_relative_local_repository_fails( "_maven_output", lambda command, cwd=None: ( "[DEBUG] Using manager EnhancedLocalRepositoryManager" - f" with priority 10.0 for {cwd / 'repo'}\n" + f" with priority 10.0 for {cwd / relative_path}\n" ), ) - with pytest.raises(RelativeLocalRepository, match="repo"): + with pytest.raises(RelativeLocalRepository): MavenInstaller().install() assert not fake_repository diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 94e017e8..2bd33074 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -131,34 +131,42 @@ def _maven_executable(local: bool) -> str | None: return shutil.which("mvn") -def _local_repository(local: bool) -> Path: - """Return the local repository that Maven reports, or ``~/.m2/repository``. +def _reported_repository(mvn: str, cwd: Path) -> Path | None: + """Return the local repository that Maven logs when it runs in ``cwd``. Maven logs the repository, then stops on a phase that does not exist before it runs a plugin, also for a default goal or goals in ``MAVEN_ARGS``. + """ + output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) + match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) + return Path(match.group(1)) if match else None + + +def _local_repository(local: bool) -> Path: + """Return the local repository that Maven reports, or ``~/.m2/repository``. + With ``local``, Maven runs in the current directory, as a build from there does. - Without ``local``, Maven runs in an empty directory to skip the project ``.mvn``. + Without ``local``, Maven runs in two empty directories to skip the project ``.mvn``. + Two different answers mean that the path is relative. """ mvn = _maven_executable(local) - if not mvn: - return Path.home() / ".m2" / "repository" - with tempfile.TemporaryDirectory() as empty_dir: - probe_dir = Path(empty_dir) - cwd = maven_tools.current_dir() if local else probe_dir - output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) - match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) - if match is None: - return Path.home() / ".m2" / "repository" - repository = Path(match.group(1)) - if not local and probe_dir.resolve() in repository.resolve().parents: - relative = repository.resolve().relative_to(probe_dir.resolve()) - raise RelativeLocalRepository( - f"Maven uses the relative local repository {relative}, so each build " - "looks for the extension in its own directory. Set an absolute path " - "for the local repository, or run " - "`cloudsmith credential-helper install maven --local` in each project." - ) - return repository + repository = None + if mvn and local: + repository = _reported_repository(mvn, maven_tools.current_dir()) + elif mvn: + with tempfile.TemporaryDirectory() as empty_dir: + nested_dir = Path(empty_dir) / "nested" + nested_dir.mkdir() + repository = _reported_repository(mvn, Path(empty_dir)) + if repository and repository != _reported_repository(mvn, nested_dir): + raise RelativeLocalRepository( + "The local repository of Maven is a relative path, so each build " + "looks for the extension in its own directory. Set an absolute " + "path for the local repository, or run " + "`cloudsmith credential-helper install maven --local` in each " + "project." + ) + return repository or Path.home() / ".m2" / "repository" def _old_maven_warning() -> list[str]: From 96f74c22ad9ac8c2ab2d8b5b1e797425e04d61b0 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:11:23 +0000 Subject: [PATCH 25/34] fix: use the default repository when the second Maven probe reports nothing Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 21 +++++++++++++++++++ .../credential_helpers/maven/installer.py | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 57e018fb..f4f83e44 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -861,6 +861,27 @@ def test_maven_install_with_a_relative_local_repository_fails( assert not fake_repository +def test_maven_install_without_a_second_answer_from_maven_uses_the_default( + home, fake_repository, outside_projects, monkeypatch +): + """When the second Maven run reports nothing, the install uses ~/.m2/repository.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + outputs = iter( + [ + "[DEBUG] Using manager EnhancedLocalRepositoryManager" + f" with priority 10.0 for {home / 'repo'}\n", + "", + ] + ) + monkeypatch.setattr( + maven_installer, "_maven_output", lambda command, cwd=None: next(outputs) + ) + + MavenInstaller().install() + + assert _jar(home / ".m2" / "repository").exists() + + @pytest.mark.parametrize( ("local", "wrapper"), [(False, True), (True, False), (True, True)], diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 2bd33074..8e8289b4 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -158,7 +158,8 @@ def _local_repository(local: bool) -> Path: nested_dir = Path(empty_dir) / "nested" nested_dir.mkdir() repository = _reported_repository(mvn, Path(empty_dir)) - if repository and repository != _reported_repository(mvn, nested_dir): + nested_repository = repository and _reported_repository(mvn, nested_dir) + if nested_repository and nested_repository != repository: raise RelativeLocalRepository( "The local repository of Maven is a relative path, so each build " "looks for the extension in its own directory. Set an absolute " @@ -166,6 +167,7 @@ def _local_repository(local: bool) -> Path: "`cloudsmith credential-helper install maven --local` in each " "project." ) + repository = nested_repository return repository or Path.home() / ".m2" / "repository" From 7baef900fdbd768ed9e321f139d19ee36c7f2752 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:28:19 +0000 Subject: [PATCH 26/34] fix: ask Gradle and sbt where they read the Cloudsmith plugins Gradle reports its user home and sbt 1 reports its global plugins directory, so -Dgradle.user.home and -Dsbt.global.base now count. Maven, Gradle and sbt share one way to run the tool and to find a relative path. status keeps the default locations, so credential-helper list stays fast. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- .../cli/commands/credential_helper/manage.py | 4 +- .../test_credential_helper_maven_tools.py | 138 +++++++++++++++--- .../credential_helpers/gradle/installer.py | 35 ++++- .../credential_helpers/maven/installer.py | 57 ++------ .../credential_helpers/maven_tools.py | 54 ++++++- .../credential_helpers/sbt/installer.py | 37 +++++ 7 files changed, 256 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a843e7ba..b0a9ff24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added `cloudsmith credential-helper install maven`, `install gradle`, and `install sbt`. They set up the Cloudsmith Maven tools, which get repository credentials from `cloudsmith credential-helper generic`. Each command installs the newest release from the public `cloudsmith/maven-tools` repository, for every build of the user. - `maven` puts the extension in the local repository that Maven reports (`~/.m2/repository` when the command cannot run `mvn`) and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space or a `:`. In these cases the command stops and tells you to use `--local` or Maven 3.10. - - `gradle` writes an init script to `~/.gradle/init.d` (or `$GRADLE_USER_HOME/init.d`), which applies the plugin to every Gradle build. - - `sbt` writes a global plugin to `~/.sbt/1.0/plugins`, which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. + - `gradle` writes an init script to `init.d` of the Gradle user home that `gradle` reports (`$GRADLE_USER_HOME` or `~/.gradle` when the command cannot run `gradle`), which applies the plugin to every Gradle build. + - `sbt` writes a global plugin to the global plugins directory that `sbt` reports (`~/.sbt/1.0/plugins` when the command cannot run `sbt`), which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. + - When Maven, Gradle or sbt reports a relative path for these locations, the command stops, because each build would look in its own directory. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. - Added `skills` to `cloudsmith upstream`. Use `cloudsmith upstream skills list`, `create`, `update`, and `delete` to manage AI Skills upstreams. An AI Skills upstream caches skills and plugins from a GitHub repository. diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 8af8085f..9daa0069 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -23,11 +23,11 @@ from cloudsmith_cli.credential_helpers.maven.installer import ( MavenInstaller, MavenrcNotSupported, - RelativeLocalRepository, ) from cloudsmith_cli.credential_helpers.maven_tools import ( ConfigFileConflict, ProjectNotFound, + RelativeConfigPath, ) from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller from cloudsmith_cli.credential_helpers.sbt.installer import SbtInstaller @@ -452,7 +452,7 @@ def install_cmd( ConfigFileConflict, UnsafeLauncherValue, MavenrcNotSupported, - RelativeLocalRepository, + RelativeConfigPath, ) as exc: raise click.ClickException(str(exc)) except OSError as exc: diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index f4f83e44..1fe11473 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -11,6 +11,7 @@ import os import shutil import subprocess +import sys from contextlib import nullcontext from pathlib import Path from unittest.mock import Mock, patch @@ -24,12 +25,12 @@ from ...credential_helpers.gradle.installer import GradleInstaller from ...credential_helpers.maven import extensions, mavenrc from ...credential_helpers.maven import installer as maven_installer -from ...credential_helpers.maven.installer import ( - MavenInstaller, - MavenrcNotSupported, - RelativeLocalRepository, +from ...credential_helpers.maven.installer import MavenInstaller, MavenrcNotSupported +from ...credential_helpers.maven_tools import ( + ConfigFileConflict, + ProjectNotFound, + RelativeConfigPath, ) -from ...credential_helpers.maven_tools import ConfigFileConflict, ProjectNotFound from ...credential_helpers.sbt.installer import SbtInstaller EXTENSION_PATH = "io/cloudsmith/cloudsmith-maven-extension" @@ -96,15 +97,15 @@ def fake_download(artifact_path, destination, session=None): @pytest.fixture(autouse=True) -def maven_debug_output(monkeypatch): - """Run as if Maven reports no local repository, and record each Maven command.""" +def tool_runs(monkeypatch): + """Run as if each build tool reports nothing, and record each command.""" commands = [] def fake_output(command, cwd=None): commands.append((command, cwd)) return "" - monkeypatch.setattr(maven_installer, "_maven_output", fake_output) + monkeypatch.setattr(maven_tools, "tool_output", fake_output) return commands @@ -743,6 +744,17 @@ def _user_extensions(home: Path) -> Path: return home / ".m2" / "extensions.xml" +def _skip_unless_it_runs(tool: str) -> None: + executable = shutil.which(tool) + if ( + executable is None + or subprocess.run( + [executable, "--version"], capture_output=True, timeout=120, check=False + ).returncode + ): + pytest.skip(f"{tool} is not installed or does not run") + + def _jar(repository: Path) -> Path: return ( repository / EXTENSION_PATH / "0.2.0" / "cloudsmith-maven-extension-0.2.0.jar" @@ -818,17 +830,16 @@ def test_maven_install_marks_the_files_as_installed( ) -@pytest.mark.parametrize("level", ["[DEBUG]", "[\x1b[1;36mDEBUG\x1b[m]"]) def test_maven_install_uses_the_local_repository_that_maven_reports( - level, home, fake_repository, outside_projects, monkeypatch + home, fake_repository, outside_projects, monkeypatch ): - """The JAR goes in the repository from the debug output of Maven, in color too, and --mavenrc loads it there.""" + """The JAR goes in the repository from the debug output of Maven, and --mavenrc loads it there.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") monkeypatch.setattr( - maven_installer, - "_maven_output", + maven_tools, + "tool_output", lambda command, cwd=None: ( - f"{level} Using manager EnhancedLocalRepositoryManager" + "[DEBUG] Using manager EnhancedLocalRepositoryManager" f" with priority 10.0 for {home / 'repo'}\n" ), ) @@ -840,6 +851,17 @@ def test_maven_install_uses_the_local_repository_that_maven_reports( assert f"-Dmaven.ext.class.path={_jar(home / 'repo')}" in text +# The parameter replaces the autouse fake, so the real tool_output runs. +@pytest.mark.parametrize("tool_runs", [None]) +def test_tool_output_has_stdout_and_stderr_without_colors(tool_runs): + """A build tool can log to stderr and in color, so the output has both streams without colors.""" + script = "import sys; print('\\x1b[1;36mDEBUG\\x1b[m out'); print('err', file=sys.stderr)" + + output = maven_tools.tool_output([sys.executable, "-c", script]) + + assert output.split() == ["DEBUG", "out", "err"] + + @pytest.mark.parametrize("relative_path", ["repo", ".", "../repo"]) def test_maven_install_with_a_relative_local_repository_fails( relative_path, home, fake_repository, outside_projects, monkeypatch @@ -847,15 +869,15 @@ def test_maven_install_with_a_relative_local_repository_fails( """Without local, a relative repository depends on the directory of each build, so the install stops.""" monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") monkeypatch.setattr( - maven_installer, - "_maven_output", + maven_tools, + "tool_output", lambda command, cwd=None: ( "[DEBUG] Using manager EnhancedLocalRepositoryManager" f" with priority 10.0 for {cwd / relative_path}\n" ), ) - with pytest.raises(RelativeLocalRepository): + with pytest.raises(RelativeConfigPath): MavenInstaller().install() assert not fake_repository @@ -874,7 +896,7 @@ def test_maven_install_without_a_second_answer_from_maven_uses_the_default( ] ) monkeypatch.setattr( - maven_installer, "_maven_output", lambda command, cwd=None: next(outputs) + maven_tools, "tool_output", lambda command, cwd=None: next(outputs) ) MavenInstaller().install() @@ -892,7 +914,7 @@ def test_maven_install_asks_the_maven_that_the_build_uses( home, fake_repository, maven_project, - maven_debug_output, + tool_runs, monkeypatch, ): """With local, the Maven wrapper runs in the current module; else mvn runs outside the project. No goal runs.""" @@ -907,7 +929,7 @@ def test_maven_install_asks_the_maven_that_the_build_uses( MavenInstaller().install(local=local) - [(command, cwd)] = maven_debug_output + [(command, cwd)] = tool_runs executable = str(mvnw) if local and wrapper else "/bin/mvn" assert command == [ executable, @@ -920,13 +942,13 @@ def test_maven_install_asks_the_maven_that_the_build_uses( @pytest.mark.windows -@pytest.mark.skipif(shutil.which("mvn") is None, reason="Maven is not installed") # The parameter replaces the autouse fake, so the real mvn runs. -@pytest.mark.parametrize("maven_debug_output", [None]) +@pytest.mark.parametrize("tool_runs", [None]) def test_maven_install_uses_the_local_repository_of_the_real_maven( - maven_debug_output, home, fake_repository, outside_projects, tmp_path, monkeypatch + tool_runs, home, fake_repository, outside_projects, tmp_path, monkeypatch ): """The real mvn reports the repository in a line that the installer finds, on stderr too.""" + _skip_unless_it_runs("mvn") monkeypatch.setenv( "MAVEN_OPTS", f"-Dmaven.repo.local={tmp_path / 'repo'}" @@ -1184,6 +1206,40 @@ def test_gradle_install_uses_gradle_user_home( assert (tmp_path / "gradle-home" / "init.d" / "cloudsmith.init.gradle.kts").exists() +def test_gradle_install_and_uninstall_use_the_user_home_that_gradle_reports( + home, fake_repository, monkeypatch +): + """The init script goes in init.d of the Gradle user home that Gradle reports.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_tools, + "tool_output", + lambda command, cwd=None: f"CLOUDSMITH_GRADLE_USER_HOME={home / 'reported'}\n", + ) + init_script = home / "reported" / "init.d" / "cloudsmith.init.gradle.kts" + + GradleInstaller().install() + assert init_script.exists() + + GradleInstaller().uninstall() + assert not init_script.exists() + + +@pytest.mark.windows +# The parameter replaces the autouse fake, so the real gradle runs. +@pytest.mark.parametrize("tool_runs", [None]) +def test_gradle_install_uses_the_user_home_of_the_real_gradle( + tool_runs, home, fake_repository, tmp_path, monkeypatch +): + """The real gradle reports gradle.user.home from GRADLE_OPTS.""" + _skip_unless_it_runs("gradle") + monkeypatch.setenv("GRADLE_OPTS", f"-Dgradle.user.home={tmp_path / 'gradle-home'}") + + GradleInstaller().install() + + assert (tmp_path / "gradle-home" / "init.d" / "cloudsmith.init.gradle.kts").exists() + + def test_gradle_uninstall_and_status(home, fake_repository): """Uninstall removes the init script, and status follows it.""" script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" @@ -1261,6 +1317,42 @@ def test_sbt_install_writes_a_global_plugin(home, fake_repository): assert any(str(plugin_file) in action for action in actions) +def test_sbt_install_and_uninstall_use_the_global_plugins_that_sbt_reports( + home, fake_repository, monkeypatch +): + """The plugin file goes in the global plugins directory that sbt 1 reports.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_tools, + "tool_output", + lambda command, cwd=None: ( + f"CLOUDSMITH_SBT_GLOBAL_PLUGINS={home / 'reported'}\n" + ), + ) + plugin_file = home / "reported" / "cloudsmith.sbt" + + SbtInstaller().install() + assert plugin_file.exists() + + SbtInstaller().uninstall() + assert not plugin_file.exists() + + +@pytest.mark.windows +# The parameter replaces the autouse fake, so the real sbt runs. +@pytest.mark.parametrize("tool_runs", [None]) +def test_sbt_install_uses_the_global_base_of_the_real_sbt( + tool_runs, home, fake_repository, tmp_path, monkeypatch +): + """The real sbt reports sbt.global.base from SBT_OPTS.""" + _skip_unless_it_runs("sbt") + monkeypatch.setenv("SBT_OPTS", f"-Dsbt.global.base={tmp_path / 'sbt-base'}") + + SbtInstaller().install() + + assert (tmp_path / "sbt-base" / "plugins" / "cloudsmith.sbt").exists() + + def test_sbt_install_local_writes_a_project_plugin( home, fake_repository, outside_projects ): diff --git a/cloudsmith_cli/credential_helpers/gradle/installer.py b/cloudsmith_cli/credential_helpers/gradle/installer.py index ec362119..763ce79c 100644 --- a/cloudsmith_cli/credential_helpers/gradle/installer.py +++ b/cloudsmith_cli/credential_helpers/gradle/installer.py @@ -4,10 +4,15 @@ from __future__ import annotations import os +import re +import shutil +from functools import partial from pathlib import Path +from .. import maven_tools from ..maven_tools import REPOSITORY_URL, ConfigFileInstaller +INIT_SCRIPT_NAME = "cloudsmith.init.gradle.kts" INIT_SCRIPT = """\ // Written by `cloudsmith credential-helper install gradle`. initscript {{ @@ -23,6 +28,21 @@ pluginManager.apply(io.cloudsmith.gradle.CloudsmithPlugin::class.java) }} """ +_PROBE_INIT_SCRIPT = ( + 'println("CLOUDSMITH_GRADLE_USER_HOME=${gradle.gradleUserHomeDir.absolutePath}")\n' +) +_USER_HOME_LINE = re.compile(r"^CLOUDSMITH_GRADLE_USER_HOME=(.+)$", re.MULTILINE) + + +def _reported_user_home(gradle: str, cwd: Path) -> Path | None: + """Return the Gradle user home that an init script prints when Gradle runs in ``cwd``.""" + probe = cwd / "probe.init.gradle" + probe.write_text(_PROBE_INIT_SCRIPT, encoding="utf-8") + output = maven_tools.tool_output( + [gradle, "-q", "--offline", "--no-daemon", "-I", str(probe), "help"], cwd + ) + match = _USER_HOME_LINE.search(output) + return Path(match.group(1)) if match else None class GradleInstaller(ConfigFileInstaller): @@ -33,7 +53,20 @@ class GradleInstaller(ConfigFileInstaller): def path(self) -> Path: gradle_user_home = os.environ.get("GRADLE_USER_HOME") base = Path(gradle_user_home) if gradle_user_home else Path.home() / ".gradle" - return base / "init.d" / "cloudsmith.init.gradle.kts" + return base / "init.d" / INIT_SCRIPT_NAME + + def tool_path(self) -> Path: + """Return the init script in the Gradle user home that ``gradle`` reports.""" + gradle = shutil.which("gradle") + if gradle is None: + return self.path() + user_home = maven_tools.ask_outside_projects( + partial(_reported_user_home, gradle), + "The Gradle user home is a relative path, so each build looks for the " + "init script in its own directory. Set an absolute path for " + "gradle.user.home.", + ) + return user_home / "init.d" / INIT_SCRIPT_NAME if user_home else self.path() def render(self, version: str) -> str: return INIT_SCRIPT.format(repository_url=REPOSITORY_URL, version=version) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 8e8289b4..e2fd8640 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -17,8 +17,6 @@ import os import re import shutil -import subprocess -import tempfile from functools import partial from pathlib import Path from typing import TYPE_CHECKING @@ -36,12 +34,10 @@ EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) -MAVEN_VERSION_TIMEOUT_SECONDS = 60 _LOCAL_REPOSITORY_LOG = re.compile( r"^\[DEBUG\] Using manager \S+ with priority \S+ for (.+)$", re.MULTILINE ) _UNKNOWN_PHASE = "cloudsmith-local-repository-probe" -_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") INSTEAD_OF_MAVENRC = ( "Run `cloudsmith credential-helper install maven --local` in each project " "(Maven 3.8 and later), or upgrade to Maven 3.10 or later and run " @@ -53,10 +49,6 @@ class MavenrcNotSupported(Exception): """Raised when the ``mvn`` script cannot load the extension from ``~/.mavenrc``.""" -class RelativeLocalRepository(Exception): - """Raised when the local repository of Maven changes with the directory of the build.""" - - def _project_dir() -> Path: """Return the directory where Maven reads ``.mvn``: the nearest one that has it.""" cwd = maven_tools.current_dir() @@ -96,29 +88,14 @@ def _read(path: Path) -> str | None: return path.read_text(encoding="utf-8", errors="surrogateescape") -def _maven_output(command: list[str], cwd: Path | None = None) -> str: - """Return what the Maven command prints, or an empty string when it does not run.""" - try: - result = subprocess.run( - command, - cwd=cwd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=MAVEN_VERSION_TIMEOUT_SECONDS, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return "" - return result.stdout - - def _maven_version() -> str | None: """Return the version that ``mvn -v`` reports, or ``None`` when there is none.""" mvn = shutil.which("mvn") if not mvn: return None - match = re.search(r"Apache Maven (\d+(?:\.\d+)+)", _maven_output([mvn, "-v"])) + match = re.search( + r"Apache Maven (\d+(?:\.\d+)+)", maven_tools.tool_output([mvn, "-v"]) + ) return match.group(1) if match else None @@ -137,8 +114,8 @@ def _reported_repository(mvn: str, cwd: Path) -> Path | None: Maven logs the repository, then stops on a phase that does not exist before it runs a plugin, also for a default goal or goals in ``MAVEN_ARGS``. """ - output = _maven_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) - match = _LOCAL_REPOSITORY_LOG.search(_ANSI_ESCAPE.sub("", output)) + output = maven_tools.tool_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) + match = _LOCAL_REPOSITORY_LOG.search(output) return Path(match.group(1)) if match else None @@ -146,28 +123,20 @@ def _local_repository(local: bool) -> Path: """Return the local repository that Maven reports, or ``~/.m2/repository``. With ``local``, Maven runs in the current directory, as a build from there does. - Without ``local``, Maven runs in two empty directories to skip the project ``.mvn``. - Two different answers mean that the path is relative. + Without ``local``, Maven runs outside projects to skip the project ``.mvn``. """ mvn = _maven_executable(local) repository = None if mvn and local: repository = _reported_repository(mvn, maven_tools.current_dir()) elif mvn: - with tempfile.TemporaryDirectory() as empty_dir: - nested_dir = Path(empty_dir) / "nested" - nested_dir.mkdir() - repository = _reported_repository(mvn, Path(empty_dir)) - nested_repository = repository and _reported_repository(mvn, nested_dir) - if nested_repository and nested_repository != repository: - raise RelativeLocalRepository( - "The local repository of Maven is a relative path, so each build " - "looks for the extension in its own directory. Set an absolute " - "path for the local repository, or run " - "`cloudsmith credential-helper install maven --local` in each " - "project." - ) - repository = nested_repository + repository = maven_tools.ask_outside_projects( + partial(_reported_repository, mvn), + "The local repository of Maven is a relative path, so each build looks " + "for the extension in its own directory. Set an absolute path for the " + "local repository, or run " + "`cloudsmith credential-helper install maven --local` in each project.", + ) return repository or Path.home() / ".m2" / "repository" diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py index f04f35b4..e9db23e5 100644 --- a/cloudsmith_cli/credential_helpers/maven_tools.py +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -14,6 +14,8 @@ import re import shutil import stat +import subprocess +import tempfile from pathlib import Path from typing import TYPE_CHECKING from xml.etree import ElementTree @@ -21,10 +23,14 @@ from ..core.session import create_requests_session if TYPE_CHECKING: + from collections.abc import Callable + import requests REPOSITORY_URL = "https://dl.cloudsmith.io/public/cloudsmith/maven-tools/maven/" TIMEOUT_SECONDS = 30 +TOOL_TIMEOUT_SECONDS = 60 +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") class ProjectNotFound(Exception): @@ -46,6 +52,46 @@ class ConfigFileConflict(Exception): """The config file has content that the installer did not write.""" +class RelativeConfigPath(Exception): + """The build tool reports a path that changes with the directory of the build.""" + + +def tool_output(command: list[str], cwd: Path | None = None) -> str: + """Return what the build tool prints, without colors, or an empty string when it does not run.""" + try: + result = subprocess.run( + command, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=TOOL_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return _ANSI_ESCAPE.sub("", result.stdout) + + +def ask_outside_projects( + ask: Callable[[Path], Path | None], relative_message: str +) -> Path | None: + """Return the path that *ask* gets from the build tool in an empty directory. + + The tool runs again in a subdirectory. A different answer means a relative + path, which changes with the directory of each build. No second answer means + that the tool failed. + """ + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as empty_dir: + nested_dir = Path(empty_dir) / "nested" + nested_dir.mkdir() + answer = ask(Path(empty_dir)) + nested_answer = ask(nested_dir) if answer else None + if nested_answer and nested_answer != answer: + raise RelativeConfigPath(relative_message) + return nested_answer + + def _get(artifact_path: str, session: requests.Session | None) -> bytes: response = (session or create_requests_session()).get( REPOSITORY_URL + artifact_path, timeout=TIMEOUT_SECONDS @@ -117,6 +163,10 @@ def local_path(self) -> Path: """Return the config file that loads the plugin for the current project.""" raise NotImplementedError + def tool_path(self) -> Path: + """Return the config file for every build where the build tool reports it.""" + return self.path() + def render(self, version: str) -> str: """Return the content of the config file for *version*.""" raise NotImplementedError @@ -148,7 +198,7 @@ def install( session: requests.Session | None = None, **_options, ) -> list[str]: - path = self.local_path() if local else self.path() + path = self.local_path() if local else self.tool_path() version = latest_version(self.artifact_path, session) text = self.render(version) if self._read_own_file(path) == text: @@ -162,7 +212,7 @@ def install( def uninstall( self, *, dry_run: bool = False, local: bool = False, **_options ) -> list[str]: - path = self.local_path() if local else self.path() + path = self.local_path() if local else self.tool_path() if self._read_own_file(path) is None: return [f"{path} not found (nothing to remove)"] if dry_run: diff --git a/cloudsmith_cli/credential_helpers/sbt/installer.py b/cloudsmith_cli/credential_helpers/sbt/installer.py index 99266f34..47bd189d 100644 --- a/cloudsmith_cli/credential_helpers/sbt/installer.py +++ b/cloudsmith_cli/credential_helpers/sbt/installer.py @@ -3,8 +3,12 @@ from __future__ import annotations +import re +import shutil +from functools import partial from pathlib import Path +from .. import maven_tools from ..maven_tools import ( REPOSITORY_URL, ConfigFileInstaller, @@ -17,6 +21,26 @@ resolvers += "cloudsmith-maven-tools" at "{repository_url}" addSbtPlugin("io.cloudsmith" % "sbt-cloudsmith" % "{version}") """ +_PROBE_BUILD = """\ +val cloudsmithGlobalPlugins = taskKey[Unit]("Print the global plugins directory of sbt 1.") +cloudsmithGlobalPlugins := { + val s = state.value + if (sbtBinaryVersion.value.startsWith("1.")) + println("CLOUDSMITH_SBT_GLOBAL_PLUGINS=" + + BuildPaths.getGlobalPluginsDirectory(s, BuildPaths.getGlobalBase(s)).getAbsolutePath) +} +""" +_GLOBAL_PLUGINS_LINE = re.compile(r"^CLOUDSMITH_SBT_GLOBAL_PLUGINS=(.+)$", re.MULTILINE) + + +def _reported_global_plugins(sbt: str, cwd: Path) -> Path | None: + """Return the global plugins directory that sbt 1 prints when it runs in ``cwd``.""" + (cwd / "build.sbt").write_text(_PROBE_BUILD, encoding="utf-8") + output = maven_tools.tool_output( + [sbt, "-batch", "-no-colors", "cloudsmithGlobalPlugins"], cwd + ) + match = _GLOBAL_PLUGINS_LINE.search(output) + return Path(match.group(1)) if match else None class SbtInstaller(ConfigFileInstaller): @@ -27,6 +51,19 @@ class SbtInstaller(ConfigFileInstaller): def path(self) -> Path: return Path.home() / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + def tool_path(self) -> Path: + """Return the plugin file in the global plugins directory that ``sbt`` reports.""" + sbt = shutil.which("sbt") + if sbt is None: + return self.path() + global_plugins = maven_tools.ask_outside_projects( + partial(_reported_global_plugins, sbt), + "The global base of sbt is a relative path, so each build looks for the " + "plugin in its own directory. Set an absolute path for sbt.global.base, " + "or run `cloudsmith credential-helper install sbt --local` in each project.", + ) + return global_plugins / "cloudsmith.sbt" if global_plugins else self.path() + def local_path(self) -> Path: # sbt loads every .sbt file in project/, so the build's own files stay as they are. project_dir = current_dir() From fe42bbee4b2c2d6aa737b33ccbf202db5cea1404 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:33:25 +0000 Subject: [PATCH 27/34] test: do not depend on the order of stdout and stderr Co-Authored-By: Claude Opus 5.5 --- cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 1fe11473..0c8ee697 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -859,7 +859,7 @@ def test_tool_output_has_stdout_and_stderr_without_colors(tool_runs): output = maven_tools.tool_output([sys.executable, "-c", script]) - assert output.split() == ["DEBUG", "out", "err"] + assert sorted(output.split()) == ["DEBUG", "err", "out"] @pytest.mark.parametrize("relative_path", ["repo", ".", "../repo"]) From 4f997981e46336ea7ce3cca88edc89acfeddfdde Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:39:31 +0000 Subject: [PATCH 28/34] fix: report a relative Gradle or sbt path cleanly on uninstall too Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 7 ++++++- .../test_credential_helper_maven_tools.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 9daa0069..307dea15 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -553,7 +553,12 @@ def uninstall_cmd( dry_run=dry_run, **_helper_options(ctx, helper), ) - except (ConfigFileParseError, ProjectNotFound, ConfigFileConflict) as exc: + except ( + ConfigFileParseError, + ProjectNotFound, + ConfigFileConflict, + RelativeConfigPath, + ) as exc: raise click.ClickException(str(exc)) except OSError as exc: raise click.ClickException( diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 0c8ee697..be5564d9 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -1563,6 +1563,24 @@ def test_cli_with_a_config_file_that_install_did_not_write_fails_cleanly( assert str(plugin_file) in result.output +@pytest.mark.parametrize("command_name", ["install", "uninstall"]) +def test_cli_with_a_relative_tool_path_fails_cleanly( + command_name, home, fake_repository, outside_projects, monkeypatch +): + """A relative Gradle user home stops the command with the reason, without a traceback.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_tools, + "tool_output", + lambda command, cwd=None: f"CLOUDSMITH_GRADLE_USER_HOME={cwd / 'rel'}\n", + ) + + result = _invoke(command_name, ["gradle"]) + + assert result.exit_code == 1 + assert "relative path" in result.output + + @pytest.mark.parametrize( ("command_name", "helper", "option"), [ From 7af4239481d3b179a3499042b73ad63ebf6e9ee7 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 18:54:42 +0000 Subject: [PATCH 29/34] fix: stop the whole build tool on timeout and keep projects out of the probe A timeout now stops the JVM that the launcher script started and fails the command, so the CLI never falls back to a default path in silence. An empty .mvn and settings.gradle stop Maven and Gradle from reading a project above the temporary directory. Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 6 +-- .../test_credential_helper_maven_tools.py | 46 +++++++++++++++++-- .../credential_helpers/gradle/installer.py | 2 + .../credential_helpers/maven/installer.py | 9 +++- .../credential_helpers/maven_tools.py | 40 ++++++++++++---- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 307dea15..7695d826 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -25,9 +25,9 @@ MavenrcNotSupported, ) from cloudsmith_cli.credential_helpers.maven_tools import ( + BuildToolError, ConfigFileConflict, ProjectNotFound, - RelativeConfigPath, ) from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller from cloudsmith_cli.credential_helpers.sbt.installer import SbtInstaller @@ -452,7 +452,7 @@ def install_cmd( ConfigFileConflict, UnsafeLauncherValue, MavenrcNotSupported, - RelativeConfigPath, + BuildToolError, ) as exc: raise click.ClickException(str(exc)) except OSError as exc: @@ -557,7 +557,7 @@ def uninstall_cmd( ConfigFileParseError, ProjectNotFound, ConfigFileConflict, - RelativeConfigPath, + BuildToolError, ) as exc: raise click.ClickException(str(exc)) except OSError as exc: diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index be5564d9..80932e0f 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -12,6 +12,8 @@ import shutil import subprocess import sys +import tempfile +import time from contextlib import nullcontext from pathlib import Path from unittest.mock import Mock, patch @@ -29,7 +31,7 @@ from ...credential_helpers.maven_tools import ( ConfigFileConflict, ProjectNotFound, - RelativeConfigPath, + BuildToolError, ) from ...credential_helpers.sbt.installer import SbtInstaller @@ -862,6 +864,36 @@ def test_tool_output_has_stdout_and_stderr_without_colors(tool_runs): assert sorted(output.split()) == ["DEBUG", "err", "out"] +@pytest.mark.skipif(sys.platform == "win32", reason="Checks a process ID with os.kill") +# The parameter replaces the autouse fake, so the real tool_output runs. +@pytest.mark.parametrize("tool_runs", [None]) +def test_tool_output_stops_the_tool_and_its_children_at_the_timeout( + tool_runs, tmp_path, monkeypatch +): + """A launcher script starts a JVM, so a timeout stops the child too, and the command fails.""" + monkeypatch.setattr(maven_tools, "TOOL_TIMEOUT_SECONDS", 2) + pid_file = tmp_path / "child.pid" + script = ( + "import pathlib, subprocess, sys, time\n" + "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" + f"pathlib.Path({str(pid_file)!r}).write_text(str(child.pid))\n" + "time.sleep(60)\n" + ) + + with pytest.raises(BuildToolError, match="did not finish"): + maven_tools.tool_output([sys.executable, "-c", script]) + + child_pid = int(pid_file.read_text()) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.1) + else: + pytest.fail(f"process {child_pid} still runs") + + @pytest.mark.parametrize("relative_path", ["repo", ".", "../repo"]) def test_maven_install_with_a_relative_local_repository_fails( relative_path, home, fake_repository, outside_projects, monkeypatch @@ -877,7 +909,7 @@ def test_maven_install_with_a_relative_local_repository_fails( ), ) - with pytest.raises(RelativeConfigPath): + with pytest.raises(BuildToolError): MavenInstaller().install() assert not fake_repository @@ -947,8 +979,14 @@ def test_maven_install_asks_the_maven_that_the_build_uses( def test_maven_install_uses_the_local_repository_of_the_real_maven( tool_runs, home, fake_repository, outside_projects, tmp_path, monkeypatch ): - """The real mvn reports the repository in a line that the installer finds, on stderr too.""" + """The real mvn reports the repository on stderr too, and a project around the temporary directory does not count.""" _skip_unless_it_runs("mvn") + project = tmp_path / "project" + (project / ".mvn").mkdir(parents=True) + (project / ".mvn" / "maven.config").write_text( + f"-Dmaven.repo.local={tmp_path / 'project-repo'}\n", encoding="utf-8" + ) + monkeypatch.setattr(tempfile, "tempdir", str(project)) monkeypatch.setenv( "MAVEN_OPTS", f"-Dmaven.repo.local={tmp_path / 'repo'}" @@ -1572,7 +1610,7 @@ def test_cli_with_a_relative_tool_path_fails_cleanly( monkeypatch.setattr( maven_tools, "tool_output", - lambda command, cwd=None: f"CLOUDSMITH_GRADLE_USER_HOME={cwd / 'rel'}\n", + lambda command, cwd: f"CLOUDSMITH_GRADLE_USER_HOME={cwd / 'rel'}\n", ) result = _invoke(command_name, ["gradle"]) diff --git a/cloudsmith_cli/credential_helpers/gradle/installer.py b/cloudsmith_cli/credential_helpers/gradle/installer.py index 763ce79c..fa7cbd93 100644 --- a/cloudsmith_cli/credential_helpers/gradle/installer.py +++ b/cloudsmith_cli/credential_helpers/gradle/installer.py @@ -36,6 +36,8 @@ def _reported_user_home(gradle: str, cwd: Path) -> Path | None: """Return the Gradle user home that an init script prints when Gradle runs in ``cwd``.""" + # Gradle looks for a settings file in the parent directories when there is none here. + (cwd / "settings.gradle").write_text("", encoding="utf-8") probe = cwd / "probe.init.gradle" probe.write_text(_PROBE_INIT_SCRIPT, encoding="utf-8") output = maven_tools.tool_output( diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index e2fd8640..64efb23e 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -119,6 +119,13 @@ def _reported_repository(mvn: str, cwd: Path) -> Path | None: return Path(match.group(1)) if match else None +def _repository_outside_projects(mvn: str, cwd: Path) -> Path | None: + """Return the local repository that Maven reports in ``cwd``, with no project config.""" + # The mvn script looks for .mvn in the parent directories when there is none here. + (cwd / ".mvn").mkdir() + return _reported_repository(mvn, cwd) + + def _local_repository(local: bool) -> Path: """Return the local repository that Maven reports, or ``~/.m2/repository``. @@ -131,7 +138,7 @@ def _local_repository(local: bool) -> Path: repository = _reported_repository(mvn, maven_tools.current_dir()) elif mvn: repository = maven_tools.ask_outside_projects( - partial(_reported_repository, mvn), + partial(_repository_outside_projects, mvn), "The local repository of Maven is a relative path, so each build looks " "for the extension in its own directory. Set an absolute path for the " "local repository, or run " diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py index e9db23e5..47b96414 100644 --- a/cloudsmith_cli/credential_helpers/maven_tools.py +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -13,6 +13,7 @@ import os import re import shutil +import signal import stat import subprocess import tempfile @@ -21,6 +22,7 @@ from xml.etree import ElementTree from ..core.session import create_requests_session +from .launchers import is_windows if TYPE_CHECKING: from collections.abc import Callable @@ -52,25 +54,47 @@ class ConfigFileConflict(Exception): """The config file has content that the installer did not write.""" -class RelativeConfigPath(Exception): - """The build tool reports a path that changes with the directory of the build.""" +class BuildToolError(Exception): + """The build tool did not report a path that every build uses.""" + + +def _stop(process: subprocess.Popen[str]) -> None: + """Stop the tool and the processes that it started, such as the JVM of a launcher script.""" + if is_windows(): + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + check=False, + ) + else: + os.killpg(process.pid, signal.SIGKILL) + process.wait() def tool_output(command: list[str], cwd: Path | None = None) -> str: """Return what the build tool prints, without colors, or an empty string when it does not run.""" try: - result = subprocess.run( + process = subprocess.Popen( command, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - timeout=TOOL_TIMEOUT_SECONDS, - check=False, + errors="replace", + start_new_session=True, ) - except (OSError, subprocess.TimeoutExpired): + except OSError: return "" - return _ANSI_ESCAPE.sub("", result.stdout) + with process: + try: + output, _ = process.communicate(timeout=TOOL_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired as exc: + _stop(process) + raise BuildToolError( + f"{Path(command[0]).name} did not finish in {TOOL_TIMEOUT_SECONDS} " + "seconds. Make sure that it runs, and then run the command again." + ) from exc + return _ANSI_ESCAPE.sub("", output) def ask_outside_projects( @@ -88,7 +112,7 @@ def ask_outside_projects( answer = ask(Path(empty_dir)) nested_answer = ask(nested_dir) if answer else None if nested_answer and nested_answer != answer: - raise RelativeConfigPath(relative_message) + raise BuildToolError(relative_message) return nested_answer From 81720b81eb2915e7b3db5874b77938f4c61e42e3 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 19:01:18 +0000 Subject: [PATCH 30/34] test: check the timeout stops child processes without process IDs Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 80932e0f..6c939729 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -864,34 +864,27 @@ def test_tool_output_has_stdout_and_stderr_without_colors(tool_runs): assert sorted(output.split()) == ["DEBUG", "err", "out"] -@pytest.mark.skipif(sys.platform == "win32", reason="Checks a process ID with os.kill") +@pytest.mark.windows # The parameter replaces the autouse fake, so the real tool_output runs. @pytest.mark.parametrize("tool_runs", [None]) def test_tool_output_stops_the_tool_and_its_children_at_the_timeout( tool_runs, tmp_path, monkeypatch ): """A launcher script starts a JVM, so a timeout stops the child too, and the command fails.""" - monkeypatch.setattr(maven_tools, "TOOL_TIMEOUT_SECONDS", 2) - pid_file = tmp_path / "child.pid" + monkeypatch.setattr(maven_tools, "TOOL_TIMEOUT_SECONDS", 1) + alive_file = tmp_path / "alive" + child = f"import pathlib, time; time.sleep(2); pathlib.Path({str(alive_file)!r}).touch()" script = ( - "import pathlib, subprocess, sys, time\n" - "child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" - f"pathlib.Path({str(pid_file)!r}).write_text(str(child.pid))\n" + "import subprocess, sys, time\n" + f"subprocess.Popen([sys.executable, '-c', {child!r}])\n" "time.sleep(60)\n" ) with pytest.raises(BuildToolError, match="did not finish"): maven_tools.tool_output([sys.executable, "-c", script]) + time.sleep(2.5) - child_pid = int(pid_file.read_text()) - for _ in range(50): - try: - os.kill(child_pid, 0) - except ProcessLookupError: - break - time.sleep(0.1) - else: - pytest.fail(f"process {child_pid} still runs") + assert not alive_file.exists() @pytest.mark.parametrize("relative_path", ["repo", ".", "../repo"]) From e9f6abe35a7454cffccaf50d81024e9ce13a6808 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 19:10:55 +0000 Subject: [PATCH 31/34] fix: warn when a build tool reports nothing, and share the probe flow When mvn, gradle or sbt is on PATH but reports no location, the command now warns and names the default it uses. The executable path is made absolute before the tool runs in the temporary directory. The empty .mvn and settings.gradle move into the shared probe, and Gradle and sbt keep only their probe. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 47 ++++++++++++++- .../credential_helpers/gradle/installer.py | 41 +++++-------- .../credential_helpers/maven/installer.py | 32 +++++----- .../credential_helpers/maven_tools.py | 60 +++++++++++++++---- .../credential_helpers/sbt/installer.py | 37 +++++------- 5 files changed, 137 insertions(+), 80 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 6c939729..e0286f38 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -100,14 +100,20 @@ def fake_download(artifact_path, destination, session=None): @pytest.fixture(autouse=True) def tool_runs(monkeypatch): - """Run as if each build tool reports nothing, and record each command.""" + """Run as if no build tool is on PATH, and as if each one reports nothing.""" commands = [] + which = shutil.which def fake_output(command, cwd=None): commands.append((command, cwd)) return "" monkeypatch.setattr(maven_tools, "tool_output", fake_output) + monkeypatch.setattr( + shutil, + "which", + lambda name: None if name in ("mvn", "gradle", "sbt") else which(name), + ) return commands @@ -924,9 +930,46 @@ def test_maven_install_without_a_second_answer_from_maven_uses_the_default( maven_tools, "tool_output", lambda command, cwd=None: next(outputs) ) - MavenInstaller().install() + actions = MavenInstaller().install() assert _jar(home / ".m2" / "repository").exists() + assert actions[0].startswith("WARNING: mvn did not report its local repository") + + +@pytest.mark.parametrize( + ("installer", "tool"), [(GradleInstaller, "gradle"), (SbtInstaller, "sbt")] +) +def test_install_warns_when_the_build_tool_reports_nothing( + installer, tool, home, fake_repository, monkeypatch +): + """A build tool on PATH that reports nothing gets the default location and a warning.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + + actions = installer().install() + + assert actions[0] == ( + f"WARNING: {tool} did not report its {installer.reported}, " + f"so the command uses {installer().path()}." + ) + assert installer().path().exists() + + +# The parameter replaces the autouse fake, so the real tool_output runs. +@pytest.mark.parametrize("tool_runs", [None]) +def test_tool_output_runs_a_tool_on_a_relative_path_in_another_directory( + tool_runs, tmp_path, monkeypatch +): + """PATH can have a relative directory, so the tool path does not depend on cwd.""" + monkeypatch.chdir(Path(sys.executable).parent) + other_dir = tmp_path / "other" + other_dir.mkdir() + + output = maven_tools.tool_output( + [os.path.join(".", Path(sys.executable).name), "-c", "print('ran')"], + other_dir, + ) + + assert output.strip() == "ran" @pytest.mark.parametrize( diff --git a/cloudsmith_cli/credential_helpers/gradle/installer.py b/cloudsmith_cli/credential_helpers/gradle/installer.py index fa7cbd93..88b66599 100644 --- a/cloudsmith_cli/credential_helpers/gradle/installer.py +++ b/cloudsmith_cli/credential_helpers/gradle/installer.py @@ -5,8 +5,6 @@ import os import re -import shutil -from functools import partial from pathlib import Path from .. import maven_tools @@ -34,41 +32,32 @@ _USER_HOME_LINE = re.compile(r"^CLOUDSMITH_GRADLE_USER_HOME=(.+)$", re.MULTILINE) -def _reported_user_home(gradle: str, cwd: Path) -> Path | None: - """Return the Gradle user home that an init script prints when Gradle runs in ``cwd``.""" - # Gradle looks for a settings file in the parent directories when there is none here. - (cwd / "settings.gradle").write_text("", encoding="utf-8") - probe = cwd / "probe.init.gradle" - probe.write_text(_PROBE_INIT_SCRIPT, encoding="utf-8") - output = maven_tools.tool_output( - [gradle, "-q", "--offline", "--no-daemon", "-I", str(probe), "help"], cwd - ) - match = _USER_HOME_LINE.search(output) - return Path(match.group(1)) if match else None - - class GradleInstaller(ConfigFileInstaller): name = "gradle" summary = "Gradle plugin that gets credentials from the Cloudsmith CLI" artifact_path = "io/cloudsmith/cloudsmith-gradle-plugin" + tool = "gradle" + reported = "Gradle user home" + relative_message = ( + "The Gradle user home is a relative path, so each build looks for the init " + "script in its own directory. Set an absolute path for gradle.user.home." + ) def path(self) -> Path: gradle_user_home = os.environ.get("GRADLE_USER_HOME") base = Path(gradle_user_home) if gradle_user_home else Path.home() / ".gradle" return base / "init.d" / INIT_SCRIPT_NAME - def tool_path(self) -> Path: - """Return the init script in the Gradle user home that ``gradle`` reports.""" - gradle = shutil.which("gradle") - if gradle is None: - return self.path() - user_home = maven_tools.ask_outside_projects( - partial(_reported_user_home, gradle), - "The Gradle user home is a relative path, so each build looks for the " - "init script in its own directory. Set an absolute path for " - "gradle.user.home.", + def reported_path(self, executable: str, cwd: Path) -> Path | None: + """Return the init script in the Gradle user home that an init script prints.""" + probe = cwd / "probe.init.gradle" + probe.write_text(_PROBE_INIT_SCRIPT, encoding="utf-8") + output = maven_tools.tool_output( + [executable, "-q", "--offline", "--no-daemon", "-I", str(probe), "help"], + cwd, ) - return user_home / "init.d" / INIT_SCRIPT_NAME if user_home else self.path() + match = _USER_HOME_LINE.search(output) + return Path(match.group(1)) / "init.d" / INIT_SCRIPT_NAME if match else None def render(self, version: str) -> str: return INIT_SCRIPT.format(repository_url=REPOSITORY_URL, version=version) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 64efb23e..3713838b 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -119,32 +119,32 @@ def _reported_repository(mvn: str, cwd: Path) -> Path | None: return Path(match.group(1)) if match else None -def _repository_outside_projects(mvn: str, cwd: Path) -> Path | None: - """Return the local repository that Maven reports in ``cwd``, with no project config.""" - # The mvn script looks for .mvn in the parent directories when there is none here. - (cwd / ".mvn").mkdir() - return _reported_repository(mvn, cwd) - - -def _local_repository(local: bool) -> Path: - """Return the local repository that Maven reports, or ``~/.m2/repository``. +def _local_repository(local: bool) -> tuple[Path, list[str]]: + """Return the local repository that Maven reports, or ``~/.m2/repository``, and any warning. With ``local``, Maven runs in the current directory, as a build from there does. Without ``local``, Maven runs outside projects to skip the project ``.mvn``. """ + default = Path.home() / ".m2" / "repository" mvn = _maven_executable(local) - repository = None - if mvn and local: + if mvn is None: + return default, [] + if local: repository = _reported_repository(mvn, maven_tools.current_dir()) - elif mvn: + else: repository = maven_tools.ask_outside_projects( - partial(_repository_outside_projects, mvn), + partial(_reported_repository, mvn), "The local repository of Maven is a relative path, so each build looks " "for the extension in its own directory. Set an absolute path for the " "local repository, or run " "`cloudsmith credential-helper install maven --local` in each project.", ) - return repository or Path.home() / ".m2" / "repository" + if repository is None: + tool = Path(mvn).name + return default, [ + maven_tools.no_answer_warning(tool, "local repository", default) + ] + return repository, [] def _old_maven_warning() -> list[str]: @@ -240,7 +240,7 @@ def install( raise MavenrcNotSupported( f"mvn.cmd on Windows does not read ~/.mavenrc. {INSTEAD_OF_MAVENRC}" ) - repository = _local_repository(local) + repository, actions = _local_repository(local) version = maven_tools.latest_version(EXTENSION_PATH, session) version_dir = _version_dir(repository, version) jar = version_dir / f"{extensions.ARTIFACT_ID}-{version}.jar" @@ -250,7 +250,7 @@ def install( f"class path at {os.pathsep}, so they cannot load {jar}. " f"{INSTEAD_OF_MAVENRC}" ) - actions = self._download(version_dir, version, dry_run, session) + actions += self._download(version_dir, version, dry_run, session) if mavenrc: return ( actions diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py index 47b96414..3fa9872b 100644 --- a/cloudsmith_cli/credential_helpers/maven_tools.py +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -17,6 +17,7 @@ import stat import subprocess import tempfile +from functools import partial from pathlib import Path from typing import TYPE_CHECKING from xml.etree import ElementTree @@ -75,7 +76,8 @@ def tool_output(command: list[str], cwd: Path | None = None) -> str: """Return what the build tool prints, without colors, or an empty string when it does not run.""" try: process = subprocess.Popen( - command, + # PATH can have a relative directory, and the tool runs in another one. + [os.path.abspath(command[0]), *command[1:]], cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -102,13 +104,17 @@ def ask_outside_projects( ) -> Path | None: """Return the path that *ask* gets from the build tool in an empty directory. + An empty ``.mvn`` and ``settings.gradle`` make each directory a project root, + so Maven and Gradle do not read a project above the temporary directory. The tool runs again in a subdirectory. A different answer means a relative path, which changes with the directory of each build. No second answer means that the tool failed. """ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as empty_dir: nested_dir = Path(empty_dir) / "nested" - nested_dir.mkdir() + for project_dir in (Path(empty_dir), nested_dir): + (project_dir / ".mvn").mkdir(parents=True) + (project_dir / "settings.gradle").write_text("", encoding="utf-8") answer = ask(Path(empty_dir)) nested_answer = ask(nested_dir) if answer else None if nested_answer and nested_answer != answer: @@ -116,6 +122,13 @@ def ask_outside_projects( return nested_answer +def no_answer_warning(tool: str, reported: str, default: Path) -> str: + """Return the warning for a build tool that ran but did not report a location.""" + return ( + f"WARNING: {tool} did not report its {reported}, so the command uses {default}." + ) + + def _get(artifact_path: str, session: requests.Session | None) -> bytes: response = (session or create_requests_session()).get( REPOSITORY_URL + artifact_path, timeout=TIMEOUT_SECONDS @@ -178,6 +191,10 @@ class ConfigFileInstaller: artifact_path = "" # The install command passes a session with its proxy and TLS settings. downloads_files = True + # The build tool that reports where it reads the config file for every build. + tool = "" + reported = "" + relative_message = "" def path(self) -> Path: """Return the config file that loads the plugin for every build.""" @@ -187,9 +204,22 @@ def local_path(self) -> Path: """Return the config file that loads the plugin for the current project.""" raise NotImplementedError - def tool_path(self) -> Path: - """Return the config file for every build where the build tool reports it.""" - return self.path() + def reported_path(self, executable: str, cwd: Path) -> Path | None: + """Return the config file in the location that the build tool reports in *cwd*.""" + raise NotImplementedError + + def _path_for_every_build(self) -> tuple[Path, list[str]]: + """Return the config file that the build tool reports, and any warning.""" + executable = shutil.which(self.tool) if self.tool else None + if executable is None: + return self.path(), [] + path = ask_outside_projects( + partial(self.reported_path, executable), self.relative_message + ) + if path is None: + default = self.path() + return default, [no_answer_warning(self.tool, self.reported, default)] + return path, [] def render(self, version: str) -> str: """Return the content of the config file for *version*.""" @@ -222,27 +252,31 @@ def install( session: requests.Session | None = None, **_options, ) -> list[str]: - path = self.local_path() if local else self.tool_path() + path, warnings = ( + (self.local_path(), []) if local else self._path_for_every_build() + ) version = latest_version(self.artifact_path, session) text = self.render(version) if self._read_own_file(path) == text: - return [f"{path} already uses version {version} (no change)"] + return [*warnings, f"{path} already uses version {version} (no change)"] if dry_run: - return [f"would write {path} for version {version}"] + return [*warnings, f"would write {path} for version {version}"] path.parent.mkdir(parents=True, exist_ok=True) write_atomically(path, text.encode("utf-8")) - return [f"wrote {path} for version {version}"] + return [*warnings, f"wrote {path} for version {version}"] def uninstall( self, *, dry_run: bool = False, local: bool = False, **_options ) -> list[str]: - path = self.local_path() if local else self.tool_path() + path, warnings = ( + (self.local_path(), []) if local else self._path_for_every_build() + ) if self._read_own_file(path) is None: - return [f"{path} not found (nothing to remove)"] + return [*warnings, f"{path} not found (nothing to remove)"] if dry_run: - return [f"would remove {path}"] + return [*warnings, f"would remove {path}"] path.unlink() - return [f"removed {path}"] + return [*warnings, f"removed {path}"] def status(self) -> dict: paths = [self.path()] diff --git a/cloudsmith_cli/credential_helpers/sbt/installer.py b/cloudsmith_cli/credential_helpers/sbt/installer.py index 47bd189d..eb5ceb91 100644 --- a/cloudsmith_cli/credential_helpers/sbt/installer.py +++ b/cloudsmith_cli/credential_helpers/sbt/installer.py @@ -4,8 +4,6 @@ from __future__ import annotations import re -import shutil -from functools import partial from pathlib import Path from .. import maven_tools @@ -33,36 +31,29 @@ _GLOBAL_PLUGINS_LINE = re.compile(r"^CLOUDSMITH_SBT_GLOBAL_PLUGINS=(.+)$", re.MULTILINE) -def _reported_global_plugins(sbt: str, cwd: Path) -> Path | None: - """Return the global plugins directory that sbt 1 prints when it runs in ``cwd``.""" - (cwd / "build.sbt").write_text(_PROBE_BUILD, encoding="utf-8") - output = maven_tools.tool_output( - [sbt, "-batch", "-no-colors", "cloudsmithGlobalPlugins"], cwd - ) - match = _GLOBAL_PLUGINS_LINE.search(output) - return Path(match.group(1)) if match else None - - class SbtInstaller(ConfigFileInstaller): name = "sbt" summary = "sbt plugin that gets credentials from the Cloudsmith CLI" artifact_path = "io/cloudsmith/sbt-cloudsmith_2.12_1.0" + tool = "sbt" + reported = "global plugins directory" + relative_message = ( + "The global base of sbt is a relative path, so each build looks for the " + "plugin in its own directory. Set an absolute path for sbt.global.base, or " + "run `cloudsmith credential-helper install sbt --local` in each project." + ) def path(self) -> Path: return Path.home() / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" - def tool_path(self) -> Path: - """Return the plugin file in the global plugins directory that ``sbt`` reports.""" - sbt = shutil.which("sbt") - if sbt is None: - return self.path() - global_plugins = maven_tools.ask_outside_projects( - partial(_reported_global_plugins, sbt), - "The global base of sbt is a relative path, so each build looks for the " - "plugin in its own directory. Set an absolute path for sbt.global.base, " - "or run `cloudsmith credential-helper install sbt --local` in each project.", + def reported_path(self, executable: str, cwd: Path) -> Path | None: + """Return the plugin file in the global plugins directory that sbt 1 prints.""" + (cwd / "build.sbt").write_text(_PROBE_BUILD, encoding="utf-8") + output = maven_tools.tool_output( + [executable, "-batch", "-no-colors", "cloudsmithGlobalPlugins"], cwd ) - return global_plugins / "cloudsmith.sbt" if global_plugins else self.path() + match = _GLOBAL_PLUGINS_LINE.search(output) + return Path(match.group(1)) / "cloudsmith.sbt" if match else None def local_path(self) -> Path: # sbt loads every .sbt file in project/, so the build's own files stay as they are. From bd43e68015460575b56ceeb2450e2c7d58d6dd7b Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 19:22:02 +0000 Subject: [PATCH 32/34] fix: stop the build tool on Ctrl+C, and find a broken sbt plugin to remove The probe runs in its own session, so Ctrl+C now stops it as well. sbt 1 names its global plugins directory before it fails to load a plugin there, so uninstall can still find a plugin that does not load. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 49 +++++++++++++++++-- .../credential_helpers/maven_tools.py | 8 ++- .../credential_helpers/sbt/installer.py | 7 +++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index e0286f38..45fdb334 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -871,13 +871,26 @@ def test_tool_output_has_stdout_and_stderr_without_colors(tool_runs): @pytest.mark.windows +@pytest.mark.parametrize( + ("interrupt", "error"), + [ + (subprocess.TimeoutExpired("tool", 1), BuildToolError), + (KeyboardInterrupt(), KeyboardInterrupt), + ], +) # The parameter replaces the autouse fake, so the real tool_output runs. @pytest.mark.parametrize("tool_runs", [None]) -def test_tool_output_stops_the_tool_and_its_children_at_the_timeout( - tool_runs, tmp_path, monkeypatch +def test_tool_output_stops_the_tool_and_its_children( + tool_runs, interrupt, error, tmp_path, monkeypatch ): - """A launcher script starts a JVM, so a timeout stops the child too, and the command fails.""" - monkeypatch.setattr(maven_tools, "TOOL_TIMEOUT_SECONDS", 1) + """A launcher script starts a JVM, so a timeout or Ctrl+C stops the child too.""" + communicate = subprocess.Popen.communicate + + def interrupted_communicate(self, timeout=None): + time.sleep(1) + raise interrupt + + monkeypatch.setattr(subprocess.Popen, "communicate", interrupted_communicate) alive_file = tmp_path / "alive" child = f"import pathlib, time; time.sleep(2); pathlib.Path({str(alive_file)!r}).touch()" script = ( @@ -886,8 +899,9 @@ def test_tool_output_stops_the_tool_and_its_children_at_the_timeout( "time.sleep(60)\n" ) - with pytest.raises(BuildToolError, match="did not finish"): + with pytest.raises(error): maven_tools.tool_output([sys.executable, "-c", script]) + monkeypatch.setattr(subprocess.Popen, "communicate", communicate) time.sleep(2.5) assert not alive_file.exists() @@ -1412,6 +1426,31 @@ def test_sbt_install_and_uninstall_use_the_global_plugins_that_sbt_reports( assert not plugin_file.exists() +@pytest.mark.parametrize( + ("sbt_version", "uses_reported"), [("1.13.0", True), ("2.0.0", False)] +) +def test_sbt_install_finds_the_global_plugins_when_a_plugin_fails_to_load( + sbt_version, uses_reported, home, fake_repository, monkeypatch +): + """sbt names the global plugins directory before it fails to load a plugin there; sbt 2 uses its own.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_tools, + "tool_output", + lambda command, cwd=None: ( + f"[info] welcome to sbt {sbt_version} (Java)\n" + f"[info] loading global plugins from {home / 'reported'}\n" + "[error] Not found\n" + ), + ) + + SbtInstaller().install() + + reported_file = home / "reported" / "cloudsmith.sbt" + assert reported_file.exists() is uses_reported + assert SbtInstaller().path().exists() is not uses_reported + + @pytest.mark.windows # The parameter replaces the autouse fake, so the real sbt runs. @pytest.mark.parametrize("tool_runs", [None]) diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py index 3fa9872b..6e935f18 100644 --- a/cloudsmith_cli/credential_helpers/maven_tools.py +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -68,7 +68,9 @@ def _stop(process: subprocess.Popen[str]) -> None: check=False, ) else: - os.killpg(process.pid, signal.SIGKILL) + # The tool can finish between the timeout and the kill. + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) process.wait() @@ -96,6 +98,10 @@ def tool_output(command: list[str], cwd: Path | None = None) -> str: f"{Path(command[0]).name} did not finish in {TOOL_TIMEOUT_SECONDS} " "seconds. Make sure that it runs, and then run the command again." ) from exc + except KeyboardInterrupt: + # The tool runs in its own session, so Ctrl+C does not reach it. + _stop(process) + raise return _ANSI_ESCAPE.sub("", output) diff --git a/cloudsmith_cli/credential_helpers/sbt/installer.py b/cloudsmith_cli/credential_helpers/sbt/installer.py index eb5ceb91..222197ce 100644 --- a/cloudsmith_cli/credential_helpers/sbt/installer.py +++ b/cloudsmith_cli/credential_helpers/sbt/installer.py @@ -29,6 +29,10 @@ } """ _GLOBAL_PLUGINS_LINE = re.compile(r"^CLOUDSMITH_SBT_GLOBAL_PLUGINS=(.+)$", re.MULTILINE) +_SBT_1_LINE = re.compile(r"^\[info\] welcome to sbt 1\.", re.MULTILINE) +_LOADING_GLOBAL_PLUGINS_LINE = re.compile( + r"^\[info\] loading global plugins from (.+)$", re.MULTILINE +) class SbtInstaller(ConfigFileInstaller): @@ -53,6 +57,9 @@ def reported_path(self, executable: str, cwd: Path) -> Path | None: [executable, "-batch", "-no-colors", "cloudsmithGlobalPlugins"], cwd ) match = _GLOBAL_PLUGINS_LINE.search(output) + if match is None and _SBT_1_LINE.search(output): + # sbt names the directory before it fails to load a plugin in it. + match = _LOADING_GLOBAL_PLUGINS_LINE.search(output) return Path(match.group(1)) / "cloudsmith.sbt" if match else None def local_path(self) -> Path: From 4c44a78289fc9d556834940dba1ee63176ee07ca Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 19:29:39 +0000 Subject: [PATCH 33/34] fix: anchor reported paths to the probe directory, and fix the Windows test A relative path that a tool prints now resolves against the directory where the tool ran, so the two probes see a relative global base of sbt. The timeout test interrupts only the tool, so taskkill can run on Windows. Co-Authored-By: Claude Opus 5.5 --- .../test_credential_helper_maven_tools.py | 24 ++++++++++++++++++- .../credential_helpers/gradle/installer.py | 2 +- .../credential_helpers/maven/installer.py | 2 +- .../credential_helpers/sbt/installer.py | 3 ++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index 45fdb334..ea2e767c 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -885,8 +885,12 @@ def test_tool_output_stops_the_tool_and_its_children( ): """A launcher script starts a JVM, so a timeout or Ctrl+C stops the child too.""" communicate = subprocess.Popen.communicate + tools = [] - def interrupted_communicate(self, timeout=None): + def interrupted_communicate(self, data=None, timeout=None): + if tools: # A later process, such as taskkill, stops the tool. + return communicate(self, data, timeout) + tools.append(self) time.sleep(1) raise interrupt @@ -1426,6 +1430,24 @@ def test_sbt_install_and_uninstall_use_the_global_plugins_that_sbt_reports( assert not plugin_file.exists() +def test_sbt_install_with_a_relative_global_base_and_a_broken_plugin_fails( + home, fake_repository, monkeypatch +): + """sbt can name a relative global plugins directory, which changes with the directory of each build.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + monkeypatch.setattr( + maven_tools, + "tool_output", + lambda command, cwd=None: ( + "[info] welcome to sbt 1.13.0 (Java)\n" + "[info] loading global plugins from relative/plugins\n" + ), + ) + + with pytest.raises(BuildToolError): + SbtInstaller().install() + + @pytest.mark.parametrize( ("sbt_version", "uses_reported"), [("1.13.0", True), ("2.0.0", False)] ) diff --git a/cloudsmith_cli/credential_helpers/gradle/installer.py b/cloudsmith_cli/credential_helpers/gradle/installer.py index 88b66599..329f43d5 100644 --- a/cloudsmith_cli/credential_helpers/gradle/installer.py +++ b/cloudsmith_cli/credential_helpers/gradle/installer.py @@ -57,7 +57,7 @@ def reported_path(self, executable: str, cwd: Path) -> Path | None: cwd, ) match = _USER_HOME_LINE.search(output) - return Path(match.group(1)) / "init.d" / INIT_SCRIPT_NAME if match else None + return cwd / match.group(1) / "init.d" / INIT_SCRIPT_NAME if match else None def render(self, version: str) -> str: return INIT_SCRIPT.format(repository_url=REPOSITORY_URL, version=version) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 3713838b..738dc062 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -116,7 +116,7 @@ def _reported_repository(mvn: str, cwd: Path) -> Path | None: """ output = maven_tools.tool_output([mvn, "-X", "-o", "-B", _UNKNOWN_PHASE], cwd) match = _LOCAL_REPOSITORY_LOG.search(output) - return Path(match.group(1)) if match else None + return cwd / match.group(1) if match else None def _local_repository(local: bool) -> tuple[Path, list[str]]: diff --git a/cloudsmith_cli/credential_helpers/sbt/installer.py b/cloudsmith_cli/credential_helpers/sbt/installer.py index 222197ce..1c3451b2 100644 --- a/cloudsmith_cli/credential_helpers/sbt/installer.py +++ b/cloudsmith_cli/credential_helpers/sbt/installer.py @@ -60,7 +60,8 @@ def reported_path(self, executable: str, cwd: Path) -> Path | None: if match is None and _SBT_1_LINE.search(output): # sbt names the directory before it fails to load a plugin in it. match = _LOADING_GLOBAL_PLUGINS_LINE.search(output) - return Path(match.group(1)) / "cloudsmith.sbt" if match else None + # A relative path is relative to cwd, so the second probe gets another one. + return cwd / match.group(1) / "cloudsmith.sbt" if match else None def local_path(self) -> Path: # sbt loads every .sbt file in project/, so the build's own files stay as they are. From 0ae392db5ed8dc90f8030145c6fb82b841f3dea3 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 20:48:04 +0000 Subject: [PATCH 34/34] feat: say why install waits for Maven, Gradle, or sbt, and document list The build tool can take a few seconds to start, so install and uninstall now print what they ask it before they run it. The list help says that it checks only the default Gradle and sbt locations. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- .../cli/commands/credential_helper/manage.py | 15 +++++++++++ .../test_credential_helper_maven_tools.py | 20 +++++++++++++++ .../credential_helpers/maven/installer.py | 13 +++++++--- .../credential_helpers/maven_tools.py | 25 ++++++++++++++++--- 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a9ff24..3f29e005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `maven` puts the extension in the local repository that Maven reports (`~/.m2/repository` when the command cannot run `mvn`) and adds it to `~/.m2/extensions.xml`, which Maven 3.10 and later read. When `mvn -v` reports an earlier Maven, the command shows a warning. With `--local`, the command adds the extension to `.mvn/extensions.xml` of the Maven project in the current directory instead. With `--mavenrc`, it adds a block to `~/.mavenrc` instead, which the `mvn` and `mvnw` scripts of all Maven versions read. `--mavenrc` does not work on Windows or when the path of the local Maven repository contains a space or a `:`. In these cases the command stops and tells you to use `--local` or Maven 3.10. - `gradle` writes an init script to `init.d` of the Gradle user home that `gradle` reports (`$GRADLE_USER_HOME` or `~/.gradle` when the command cannot run `gradle`), which applies the plugin to every Gradle build. - `sbt` writes a global plugin to the global plugins directory that `sbt` reports (`~/.sbt/1.0/plugins` when the command cannot run `sbt`), which adds the plugin to every sbt 1 build. With `--local`, it writes `project/cloudsmith.sbt` of the sbt project in the current directory instead. - - When Maven, Gradle or sbt reports a relative path for these locations, the command stops, because each build would look in its own directory. + - To find these locations, the commands run Maven, Gradle or sbt, which can take a few seconds. The commands show a message while they wait. When Maven, Gradle or sbt reports a relative path for these locations, the command stops, because each build would look in its own directory. + - `credential-helper list` checks only the default locations of Gradle and sbt, so a location that `gradle.user.home` or `sbt.global.base` moves shows as not installed. - The commands add to existing files and keep their other content. `uninstall` removes only what `install` added. - Added `skills` to `cloudsmith upstream`. Use `cloudsmith upstream skills list`, `create`, `update`, and `delete` to manage AI Skills upstreams. An AI Skills upstream caches skills and plugins from a GitHub repository. diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 7695d826..87980080 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -9,6 +9,7 @@ from __future__ import annotations import sys +from functools import partial from urllib.parse import urlsplit import click @@ -439,6 +440,7 @@ def install_cmd( if getattr(installer, "KEEPS_INSTALL_TIME_SETTINGS", False): install_kwargs["launcher_env"] = _launcher_settings_from_cli(ctx) + install_kwargs.update(_progress_option(installer)) try: actions = installer.install(**install_kwargs) @@ -552,6 +554,7 @@ def uninstall_cmd( bin_dir=bin_dir, dry_run=dry_run, **_helper_options(ctx, helper), + **_progress_option(installer), ) except ( ConfigFileParseError, @@ -585,6 +588,13 @@ def uninstall_cmd( click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow") +def _progress_option(installer) -> dict: + """Return the callback that says why the command waits, for an installer that asks a build tool.""" + if getattr(installer, "ASKS_BUILD_TOOL", False): + return {"progress": partial(click.echo, err=True)} + return {} + + # --------------------------------------------------------------------------- # list # --------------------------------------------------------------------------- @@ -600,6 +610,11 @@ def list_cmd(ctx, opts) -> None: Shows which helpers are available, whether their launcher binary is present on PATH, and which registry hosts they are configured for. + For gradle and sbt, list checks only the default locations: init.d in + $GRADLE_USER_HOME or ~/.gradle, and ~/.sbt/1.0/plugins. Install and + uninstall ask Gradle and sbt where they read plugins. If gradle.user.home + or sbt.global.base moves that location, list shows "not installed". + Example: \b diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index ea2e767c..67764cbe 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -1698,6 +1698,26 @@ def test_cli_with_a_config_file_that_install_did_not_write_fails_cleanly( assert str(plugin_file) in result.output +@pytest.mark.parametrize( + ("command_name", "helper", "message"), + [ + ("install", "maven", "Asking mvn for its local repository."), + ("install", "gradle", "Asking gradle for its Gradle user home."), + ("uninstall", "sbt", "Asking sbt for its global plugins directory."), + ], +) +def test_cli_says_why_it_waits_for_the_build_tool( + command_name, helper, message, home, fake_repository, outside_projects, monkeypatch +): + """A build tool can take some seconds to start, so the command says what it waits for.""" + monkeypatch.setattr(maven_installer.shutil, "which", lambda name: f"/bin/{name}") + + result = _invoke(command_name, [helper]) + + assert result.exit_code == 0 + assert f"{message} This can take a few seconds." in result.output + + @pytest.mark.parametrize("command_name", ["install", "uninstall"]) def test_cli_with_a_relative_tool_path_fails_cleanly( command_name, home, fake_repository, outside_projects, monkeypatch diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py index 738dc062..99b8ad79 100644 --- a/cloudsmith_cli/credential_helpers/maven/installer.py +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -119,7 +119,9 @@ def _reported_repository(mvn: str, cwd: Path) -> Path | None: return cwd / match.group(1) if match else None -def _local_repository(local: bool) -> tuple[Path, list[str]]: +def _local_repository( + local: bool, progress: Callable[[str], None] | None +) -> tuple[Path, list[str]]: """Return the local repository that Maven reports, or ``~/.m2/repository``, and any warning. With ``local``, Maven runs in the current directory, as a build from there does. @@ -129,6 +131,9 @@ def _local_repository(local: bool) -> tuple[Path, list[str]]: mvn = _maven_executable(local) if mvn is None: return default, [] + tool = Path(mvn).name + if progress: + progress(maven_tools.waiting_message(tool, "local repository")) if local: repository = _reported_repository(mvn, maven_tools.current_dir()) else: @@ -140,7 +145,6 @@ def _local_repository(local: bool) -> tuple[Path, list[str]]: "`cloudsmith credential-helper install maven --local` in each project.", ) if repository is None: - tool = Path(mvn).name return default, [ maven_tools.no_answer_warning(tool, "local repository", default) ] @@ -224,6 +228,8 @@ class MavenInstaller: summary = "Maven extension that gets credentials from the Cloudsmith CLI" # The install command passes a session with its proxy and TLS settings. downloads_files = True + # The install command passes a callback that shows the waiting message. + ASKS_BUILD_TOOL = True def install( self, @@ -232,6 +238,7 @@ def install( local: bool = False, mavenrc: bool = False, session: requests.Session | None = None, + progress: Callable[[str], None] | None = None, **_options, ) -> list[str]: """Put the extension in the local repository of Maven and turn it on.""" @@ -240,7 +247,7 @@ def install( raise MavenrcNotSupported( f"mvn.cmd on Windows does not read ~/.mavenrc. {INSTEAD_OF_MAVENRC}" ) - repository, actions = _local_repository(local) + repository, actions = _local_repository(local, progress) version = maven_tools.latest_version(EXTENSION_PATH, session) version_dir = _version_dir(repository, version) jar = version_dir / f"{extensions.ARTIFACT_ID}-{version}.jar" diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py index 6e935f18..562b88d0 100644 --- a/cloudsmith_cli/credential_helpers/maven_tools.py +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -128,6 +128,11 @@ def ask_outside_projects( return nested_answer +def waiting_message(tool: str, reported: str) -> str: + """Return the message to show while the build tool starts, which can take some seconds.""" + return f"Asking {tool} for its {reported}. This can take a few seconds." + + def no_answer_warning(tool: str, reported: str, default: Path) -> str: """Return the warning for a build tool that ran but did not report a location.""" return ( @@ -197,6 +202,8 @@ class ConfigFileInstaller: artifact_path = "" # The install command passes a session with its proxy and TLS settings. downloads_files = True + # The install and uninstall commands pass a callback that shows the waiting message. + ASKS_BUILD_TOOL = True # The build tool that reports where it reads the config file for every build. tool = "" reported = "" @@ -214,11 +221,15 @@ def reported_path(self, executable: str, cwd: Path) -> Path | None: """Return the config file in the location that the build tool reports in *cwd*.""" raise NotImplementedError - def _path_for_every_build(self) -> tuple[Path, list[str]]: + def _path_for_every_build( + self, progress: Callable[[str], None] | None + ) -> tuple[Path, list[str]]: """Return the config file that the build tool reports, and any warning.""" executable = shutil.which(self.tool) if self.tool else None if executable is None: return self.path(), [] + if progress: + progress(waiting_message(self.tool, self.reported)) path = ask_outside_projects( partial(self.reported_path, executable), self.relative_message ) @@ -256,10 +267,11 @@ def install( dry_run: bool = False, local: bool = False, session: requests.Session | None = None, + progress: Callable[[str], None] | None = None, **_options, ) -> list[str]: path, warnings = ( - (self.local_path(), []) if local else self._path_for_every_build() + (self.local_path(), []) if local else self._path_for_every_build(progress) ) version = latest_version(self.artifact_path, session) text = self.render(version) @@ -272,10 +284,15 @@ def install( return [*warnings, f"wrote {path} for version {version}"] def uninstall( - self, *, dry_run: bool = False, local: bool = False, **_options + self, + *, + dry_run: bool = False, + local: bool = False, + progress: Callable[[str], None] | None = None, + **_options, ) -> list[str]: path, warnings = ( - (self.local_path(), []) if local else self._path_for_every_build() + (self.local_path(), []) if local else self._path_for_every_build(progress) ) if self._read_own_file(path) is None: return [*warnings, f"{path} not found (nothing to remove)"]