diff --git a/CHANGELOG.md b/CHANGELOG.md index 3955961b..ec7b1ece 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### 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. + - `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. + ### Changed - After a SAML login, `cloudsmith auth` saves the Workspace to `config.ini`. It writes to the `[profile:NAME]` section of the selected profile, or to `[default]` when you do not select a profile. The CLI writes to the file or directory that `--config-file` gives, or else to your user-level `config.ini`. If the `config.ini` does not exist, the CLI creates it. diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 34475753..e76fa75d 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -17,8 +17,16 @@ from cloudsmith_cli.core.cache_utils import ConfigFileParseError from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller from cloudsmith_cli.credential_helpers.generic import PartialInstallError +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_tools import ( + ConfigFileConflict, + ProjectNotFound, +) from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller +from cloudsmith_cli.credential_helpers.sbt.installer import SbtInstaller from cloudsmith_cli.credential_helpers.terraform.installer import ( TerraformHelperExeNotFound, TerraformInstaller, @@ -29,6 +37,7 @@ TerraformrcConflictError, ) +from ....core.session import create_requests_session from ....credential_helpers.docker.installer import DockerInstaller from ... import utils from ...decorators import ( @@ -47,6 +56,9 @@ "pnpm": PNPMInstaller, "cargo": CargoInstaller, "terraform": TerraformInstaller, + "maven": MavenInstaller, + "gradle": GradleInstaller, + "sbt": SbtInstaller, } @@ -79,6 +91,42 @@ def _get_installer(name: str): return cls() +class HelperOption(click.Option): + """A flag that only the helpers in *helpers* accept.""" + + def __init__(self, *args, helpers: tuple[str, ...], **kwargs): + super().__init__(*args, **kwargs) + self.helpers = helpers + + +def _helper_options(ctx: click.Context, helper: str) -> dict[str, bool]: + """Return the helper flags that were given, or fail if *helper* does not accept one.""" + given = {} + for param in ctx.command.params: + if not isinstance(param, HelperOption) or not ctx.params[param.name]: + continue + if helper not in param.helpers: + raise click.UsageError( + f"{param.opts[0]} applies only to {' and '.join(param.helpers)}.", ctx + ) + given[param.name] = True + return given + + +def _download_session(opts): + """Return a session with the proxy and TLS settings of the command. + + The session goes to the public maven-tools repository, so it does not get + the --api-headers that are for the API host. + """ + return create_requests_session( + proxy=opts.proxy, + ssl_verify=opts.ssl_verify, + ssl_verify_strict=opts.ssl_verify_strict, + user_agent=opts.api_user_agent, + ) + + def _terraform_helper_args(ctx, opts, repo: str | None = None) -> tuple[str, ...]: """Build the terraformrc ``args`` list from the resolved config. @@ -290,6 +338,24 @@ def _terraform_workspace_next_steps(helper_args: tuple[str, ...]) -> list[str]: "list (as `-r `) so `terraform init` needs no CLOUDSMITH_REPO env " "var. Terraform does not pass the repository to a credentials helper.", ) +@click.option( + "--local", + cls=HelperOption, + helpers=("maven", "sbt"), + is_flag=True, + default=False, + help="Maven and sbt only: set up the project in the current directory " + "instead of every build of the user.", +) +@click.option( + "--mavenrc", + cls=HelperOption, + helpers=("maven",), + is_flag=True, + default=False, + help="Maven only: load the extension through ~/.mavenrc instead of " + "~/.m2/extensions.xml. Use it for Maven 3.9 and earlier.", +) @common_cli_config_options @common_cli_output_options @common_api_auth_options @@ -305,6 +371,8 @@ def install_cmd( no_discover: bool, refresh: bool, repo: str | None, + local: bool, + mavenrc: bool, ) -> None: """Install a credential helper launcher and configure the package manager. @@ -339,6 +407,9 @@ def install_cmd( $ cloudsmith credential-helper install terraform -w acme --repo my-repo """ installer = _get_installer(helper) + helper_options = _helper_options(ctx, helper) + if local and mavenrc: + raise click.UsageError("Use --local or --mavenrc, not both.", ctx) install_kwargs = { "bin_dir": bin_dir, @@ -349,7 +420,10 @@ def install_cmd( "org": opts.org, "credential": opts.credential, "api_host": opts.api_host, + **helper_options, } + if getattr(installer, "downloads_files", False): + install_kwargs["session"] = _download_session(opts) # Terraform bakes the resolved org/profile into the terraformrc `args` list # so `terraform init` needs neither env vars nor a hand-edited config. The @@ -369,6 +443,9 @@ def install_cmd( TerraformrcConflictError, TerraformHelperExeNotFound, TerraformPluginDirError, + ProjectNotFound, + ExtensionsFileError, + ConfigFileConflict, UnsafeLauncherValue, ) as exc: raise click.ClickException(str(exc)) @@ -434,10 +511,21 @@ def install_cmd( default=False, help="Show what would be done without making any changes.", ) +@click.option( + "--local", + cls=HelperOption, + helpers=("maven", "sbt"), + is_flag=True, + default=False, + help="Maven and sbt only: remove the setup of the project in the current " + "directory.", +) @common_cli_config_options @common_cli_output_options @click.pass_context -def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) -> None: +def uninstall_cmd( + ctx, opts, helper: str, bin_dir: str | None, dry_run: bool, local: bool +) -> None: """Uninstall a credential helper launcher and remove its config entries. HELPER is the name of the credential helper to uninstall (e.g. ``docker``). @@ -454,8 +542,12 @@ def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) -> """ installer = _get_installer(helper) try: - actions = installer.uninstall(bin_dir=bin_dir, dry_run=dry_run) - except ConfigFileParseError as exc: + actions = installer.uninstall( + bin_dir=bin_dir, + dry_run=dry_run, + **_helper_options(ctx, helper), + ) + except (ConfigFileParseError, ProjectNotFound, ConfigFileConflict) 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 new file mode 100644 index 00000000..6246a6e3 --- /dev/null +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -0,0 +1,1302 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven, Gradle, and sbt installers of the Cloudsmith Maven tools. + +The installers configure each build tool to load the Cloudsmith extension or +plugin, which gets its credential from `cloudsmith credential-helper generic`. +""" + +from __future__ import annotations + +import errno +import os +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch + +import click.testing +import httpretty +import httpretty.core +import pytest + +from ...credential_helpers import maven_tools +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_tools import ConfigFileConflict, ProjectNotFound +from ...credential_helpers.sbt.installer import SbtInstaller + +EXTENSION_PATH = "io/cloudsmith/cloudsmith-maven-extension" +METADATA = """ + + io.cloudsmith + cloudsmith-maven-extension + + 0.3.0-SNAPSHOT + 0.2.0 + 0.1.00.2.0 + + +""" +OUR_EXTENSION = """ + io.cloudsmith + cloudsmith-maven-extension + 0.1.0 + +""" +OTHER_EXTENSION = """ + kr.motd.maven + os-maven-plugin + 1.7.1 + +""" + + +@pytest.fixture(autouse=True) +def _fake_socket_shutdown(monkeypatch): + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + + +@pytest.fixture() +def home(tmp_path, monkeypatch): + """Point the home directory at an empty directory.""" + home_dir = tmp_path / "home" + home_dir.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: home_dir)) + monkeypatch.delenv("GRADLE_USER_HOME", raising=False) + return home_dir + + +@pytest.fixture() +def fake_repository(monkeypatch): + """Serve version 0.2.0 of every artifact without network access.""" + downloads = [] + + def fake_download(artifact_path, destination, session=None): + downloads.append(artifact_path) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(f"content of {artifact_path}", encoding="utf-8") + + monkeypatch.setattr( + maven_tools, "latest_version", lambda artifact_path, session=None: "0.2.0" + ) + monkeypatch.setattr(maven_tools, "download", fake_download) + return downloads + + +@pytest.fixture(autouse=True) +def _maven_3_10(monkeypatch): + """Run as if `mvn -v` reports Maven 3.10.0.""" + monkeypatch.setattr(maven_installer, "_maven_version", lambda: "3.10.0") + + +@pytest.fixture() +def outside_projects(tmp_path, monkeypatch): + """Run the test from a directory that is not in a project.""" + directory = tmp_path / "work" + directory.mkdir() + monkeypatch.chdir(directory) + return directory + + +@pytest.fixture() +def maven_project(tmp_path, monkeypatch): + """Make a Maven project and run the test from its root directory.""" + project = tmp_path / "project" + project.mkdir() + (project / "pom.xml").write_text("", encoding="utf-8") + monkeypatch.chdir(project) + return project + + +# --------------------------------------------------------------------------- +# 1. The public maven-tools repository +# --------------------------------------------------------------------------- + + +@httpretty.activate(allow_net_connect=False) +def test_latest_version_reads_the_release_from_the_metadata(): + """The release version is used, not a newer snapshot in .""" + httpretty.register_uri( + httpretty.GET, + f"{maven_tools.REPOSITORY_URL}{EXTENSION_PATH}/maven-metadata.xml", + body=METADATA, + ) + + assert maven_tools.latest_version(EXTENSION_PATH) == "0.2.0" + + +@httpretty.activate(allow_net_connect=False) +def test_latest_version_fails_with_an_os_error_when_the_artifact_is_missing(): + """A missing artifact is an OSError, which the install command reports.""" + httpretty.register_uri( + httpretty.GET, + f"{maven_tools.REPOSITORY_URL}{EXTENSION_PATH}/maven-metadata.xml", + status=404, + ) + + with pytest.raises(OSError): + maven_tools.latest_version(EXTENSION_PATH) + + +@httpretty.activate(allow_net_connect=False) +def test_download_writes_the_file(tmp_path): + """The file is written to the destination, and missing directories are made.""" + httpretty.register_uri( + httpretty.GET, + f"{maven_tools.REPOSITORY_URL}{EXTENSION_PATH}/0.2.0/extension.jar", + body=b"jar bytes", + ) + destination = tmp_path / "a" / "b" / "extension.jar" + + maven_tools.download(f"{EXTENSION_PATH}/0.2.0/extension.jar", destination) + + assert destination.read_bytes() == b"jar bytes" + + +@httpretty.activate(allow_net_connect=False) +def test_download_leaves_no_partial_file_when_the_write_fails(tmp_path, monkeypatch): + """A later install finds no file, so it downloads the file again.""" + httpretty.register_uri( + httpretty.GET, + f"{maven_tools.REPOSITORY_URL}{EXTENSION_PATH}/0.2.0/extension.jar", + body=b"jar bytes", + ) + + def write_half_and_fail(path, data): + with open(path, "wb") as partial_file: + partial_file.write(data[: len(data) // 2]) + raise OSError(errno.ENOSPC, "No space left on device") + + monkeypatch.setattr(Path, "write_bytes", write_half_and_fail) + destination = tmp_path / "extension.jar" + + with pytest.raises(OSError, match="No space left"): + maven_tools.download(f"{EXTENSION_PATH}/0.2.0/extension.jar", destination) + + assert list(tmp_path.iterdir()) == [] + + +def _write_half_and_fail(path, data): + with open(path, "wb") as partial_file: + partial_file.write(data[: len(data) // 2]) + raise OSError(errno.ENOSPC, "No space left on device") + + +def test_write_atomically_keeps_the_old_file_when_the_write_fails( + tmp_path, monkeypatch +): + """A full disk does not leave a config file with only part of its text.""" + path = tmp_path / ".mavenrc" + path.write_bytes(b"export A=1\n") + monkeypatch.setattr(Path, "write_bytes", _write_half_and_fail) + + with pytest.raises(OSError, match="No space left"): + maven_tools.write_atomically(path, b"export A=1\nexport B=2\n") + + assert path.read_bytes() == b"export A=1\n" + assert [entry.name for entry in tmp_path.iterdir()] == [".mavenrc"] + + +@pytest.mark.skipif(os.name == "nt", reason="Windows has no POSIX file modes.") +def test_write_atomically_never_widens_the_mode_of_a_private_file( + tmp_path, monkeypatch +): + """The temporary copy of a private file is never readable by other users.""" + path = tmp_path / ".mavenrc" + path.write_bytes(b"export SECRET=1\n") + path.chmod(0o600) + modes_while_writing = [] + write_bytes = Path.write_bytes + + def record_mode_and_write(partial, data): + if partial.exists(): + modes_while_writing.append(partial.stat().st_mode & 0o777) + else: + modes_while_writing.append(None) + return write_bytes(partial, data) + + monkeypatch.setattr(Path, "write_bytes", record_mode_and_write) + + maven_tools.write_atomically(path, b"export SECRET=2\n") + + assert modes_while_writing == [0o600] + assert path.stat().st_mode & 0o777 == 0o600 + + +@pytest.mark.skipif(os.name == "nt", reason="Symbolic links need extra rights.") +def test_write_atomically_keeps_a_symbolic_link_and_the_file_mode(tmp_path): + """A dotfile that links to another file stays a link, with the same mode.""" + target = tmp_path / "dotfiles" / "mavenrc" + target.parent.mkdir() + target.write_bytes(b"export A=1\n") + target.chmod(0o600) + link = tmp_path / ".mavenrc" + link.symlink_to(target) + + maven_tools.write_atomically(link, b"export B=2\n") + + assert link.is_symlink() + assert target.read_bytes() == b"export B=2\n" + assert target.stat().st_mode & 0o777 == 0o600 + + +# --------------------------------------------------------------------------- +# 2. .mvn/extensions.xml +# --------------------------------------------------------------------------- + + +def test_add_extension_makes_a_new_file(): + """Without a file, the result lists only the Cloudsmith extension.""" + text, changed = extensions.add_extension(None, "0.1.0") + + assert changed is True + assert text == f"\n{OUR_EXTENSION}\n" + + +def test_add_extension_keeps_other_extensions(): + """The Cloudsmith extension is added after the extensions already listed.""" + existing = f'\n\n{OTHER_EXTENSION}\n' + + text, changed = extensions.add_extension(existing, "0.1.0") + + assert changed is True + assert text == ( + f'\n\n{OTHER_EXTENSION}{OUR_EXTENSION}' + "\n" + ) + + +@pytest.mark.parametrize("closing_tag", ["", ""]) +def test_add_extension_finds_a_closing_tag_with_spaces(closing_tag): + """XML allows spaces before the > of the closing tag.""" + existing = f"\n{OTHER_EXTENSION}{closing_tag}\n" + + assert extensions.add_extension(existing, "0.1.0") == ( + f"\n{OTHER_EXTENSION}{OUR_EXTENSION}{closing_tag}\n", + True, + ) + + +def test_add_extension_to_an_empty_element(): + """A self-closing element is opened to hold the extension.""" + text, changed = extensions.add_extension("\n", "0.1.0") + + assert changed is True + assert text == f"\n{OUR_EXTENSION}\n" + + +def test_add_extension_updates_the_version(): + """An older version of the Cloudsmith extension is changed in place.""" + existing = f"\n{OUR_EXTENSION}{OTHER_EXTENSION}\n" + + text, changed = extensions.add_extension(existing, "0.2.0") + + assert changed is True + assert text == existing.replace( + "0.1.0", "0.2.0" + ) + + +def test_add_extension_with_the_same_version_changes_nothing(): + """Installing the same version again leaves the file as it is.""" + existing = f"\n{OUR_EXTENSION}\n" + + assert extensions.add_extension(existing, "0.1.0") == (existing, False) + + +def test_add_extension_ignores_a_commented_out_extension(): + """A Cloudsmith extension in a comment is not active, so a new entry is added.""" + comment = f" \n" + existing = f"\n{comment}\n" + + text, changed = extensions.add_extension(existing, "0.2.0") + + assert changed is True + assert text == ( + f"\n{comment}" + f"{OUR_EXTENSION.replace('0.1.0', '0.2.0')}\n" + ) + + +def test_add_extension_ignores_a_version_in_a_comment(): + """Only the element of the extension changes.""" + existing = f"\n{OUR_EXTENSION}\n".replace( + " \n", " \n \n" + ) + + text, _ = extensions.add_extension(existing, "0.2.0") + + assert "" in text + assert "0.2.0" in text + + +def test_remove_extension_ignores_a_commented_out_extension(): + """A commented-out entry is not removed, and does not count as another extension.""" + comment = f" \n" + + assert extensions.remove_extension( + f"\n{comment}{OUR_EXTENSION}\n" + ) == (f"\n{comment}\n", True) + assert extensions.remove_extension(f"\n{comment}\n") == ( + f"\n{comment}\n", + False, + ) + + +def test_remove_extension_keeps_other_extensions(): + """Only the Cloudsmith extension is removed.""" + existing = f"\n{OTHER_EXTENSION}{OUR_EXTENSION}\n" + + text, changed = extensions.remove_extension(existing) + + assert changed is True + assert text == f"\n{OTHER_EXTENSION}\n" + + +def test_remove_extension_keeps_comments_on_the_same_lines(): + """A comment next to the Cloudsmith extension is not part of it.""" + ours = OUR_EXTENSION.rstrip("\n") + existing = ( + f"\n{ours}\n" + f"{OTHER_EXTENSION}\n" + ) + + text, changed = extensions.remove_extension(existing) + + assert changed is True + assert text == ( + f"\n\n{OTHER_EXTENSION}\n" + ) + + +def test_remove_extension_keeps_a_file_that_is_not_utf_8(): + """A byte that is not UTF-8 stops the XML check, so the file stays.""" + existing = f"\n\n{OUR_EXTENSION}{OTHER_EXTENSION}\n" + + assert extensions.remove_extension(existing) == ( + f"\n\n{OTHER_EXTENSION}\n", + True, + ) + + +def test_remove_the_only_extension_returns_no_text(): + """When nothing else is listed, the file is not needed.""" + assert extensions.remove_extension( + f"\n{OUR_EXTENSION}\n" + ) == (None, True) + + +def test_remove_extension_without_the_extension_changes_nothing(): + """A file without the Cloudsmith extension stays as it is.""" + existing = f"\n{OTHER_EXTENSION}\n" + + assert extensions.remove_extension(existing) == (existing, False) + + +def _spaced_tags(extension: str) -> str: + return extension.replace("", "").replace( + "", "" + ) + + +NAMESPACED_EXTENSION = """ + io.cloudsmith + cloudsmith-maven-extension + 0.1.0 + +""" + + +def test_extension_with_namespaces_is_found_updated_and_removed(): + """A namespace prefix or attribute does not hide the Cloudsmith extension.""" + existing = f"\n{NAMESPACED_EXTENSION}{OTHER_EXTENSION}\n" + + updated, changed = extensions.add_extension(existing, "0.2.0") + + assert extensions.has_extension(existing) + assert changed is True + assert updated == existing.replace("0.1.0<", "0.2.0<") + assert extensions.remove_extension(existing) == ( + f"\n{OTHER_EXTENSION}\n", + True, + ) + + +@pytest.mark.parametrize( + "existing", + [ + '\n' + "\n", + '\n', + ], +) +def test_add_extension_uses_the_prefix_of_the_extensions_element(existing): + """The new extension is in the same namespace as the element.""" + text, changed = extensions.add_extension(existing, "0.2.0") + + assert changed is True + assert text == ( + '\n' + " \n" + " io.cloudsmith\n" + " cloudsmith-maven-extension\n" + " 0.2.0\n" + " \n" + "\n" + ) + assert extensions.has_extension(text) + + +def test_a_self_closing_extension_is_not_the_start_of_a_block(): + """An empty element does not join the next extension.""" + existing = f"\n \n{OUR_EXTENSION}\n" + + assert extensions.remove_extension(existing) == ( + "\n \n\n", + True, + ) + + +def test_remove_extension_keeps_an_extension_with_spaces_in_its_tags(): + """XML allows spaces before the > of a tag, so the other extension still counts.""" + other = _spaced_tags(OTHER_EXTENSION) + + assert extensions.remove_extension( + f"\n{OUR_EXTENSION}{other}\n" + ) == (f"\n{other}\n", True) + + +def test_remove_extension_keeps_a_file_that_cannot_be_parsed(): + """When the rest of the file is not valid XML, the file stays.""" + broken = " \n kr.motd.maven\n" + existing = f"\n{OUR_EXTENSION}{broken}\n" + + assert extensions.remove_extension(existing) == ( + f"\n{broken}\n", + True, + ) + + +def test_add_extension_finds_the_extension_with_spaces_in_its_tags(): + """An entry with spaces in its tags or values is updated, not listed a second time.""" + spaced = _spaced_tags(OUR_EXTENSION).replace( + "io.cloudsmith", " io.cloudsmith " + ) + existing = f"\n{spaced}\n" + + text, changed = extensions.add_extension(existing, "0.2.0") + + assert changed is True + assert text == existing.replace( + "0.1.0", "0.2.0" + ) + + +def _spaced_child_tags(extension: str) -> str: + for name in ("groupId", "artifactId", "version"): + extension = extension.replace(f"<{name}>", f"<{name} >") + extension = extension.replace(f"", f"") + return extension + + +def test_add_extension_finds_the_extension_with_spaces_in_its_child_tags(): + """XML allows spaces before the > of each child tag, so only the version changes.""" + existing = f"\n{_spaced_child_tags(OUR_EXTENSION)}\n" + + assert extensions.add_extension(existing, "0.2.0") == ( + existing.replace(">0.1.0<", ">0.2.0<"), + True, + ) + + +def test_remove_extension_finds_the_extension_with_spaces_in_its_child_tags(): + """Status and uninstall find the entry with spaces in its child tags.""" + existing = f"\n{_spaced_child_tags(OUR_EXTENSION)}\n" + + assert extensions.has_extension(existing) + assert extensions.remove_extension(existing) == (None, True) + + +# --------------------------------------------------------------------------- +# 3. ~/.mavenrc +# --------------------------------------------------------------------------- + +JAR = Path("/home/me/.m2/repository/cloudsmith-maven-extension-0.2.0.jar") +BLOCK = mavenrc.render_block(JAR) + + +def test_add_block_makes_a_new_file(): + """Without a file, the result is only the block.""" + assert mavenrc.add_block(None, JAR) == (BLOCK, True) + + +def test_add_block_keeps_the_existing_lines(): + """The block goes after the lines of the user, after a blank line.""" + existing = 'export JAVA_HOME="/opt/java"' + + assert mavenrc.add_block(existing, JAR) == (f"{existing}\n\n{BLOCK}", True) + + +def test_add_block_replaces_an_older_block_in_place(): + """An upgrade changes the block, and the lines around it stay as they are.""" + older = BLOCK.replace("0.2.0", "0.1.0") + + assert mavenrc.add_block(f"export A=1\n\n{older}export B=2\n", JAR) == ( + f"export A=1\n\n{BLOCK}export B=2\n", + True, + ) + + +def test_add_block_with_the_same_jar_changes_nothing(): + """Installing the same version again leaves the file as it is.""" + existing = f"export A=1\n\n{BLOCK}" + + assert mavenrc.add_block(existing, JAR) == (existing, False) + + +def test_remove_block_keeps_the_other_lines(): + """Only the block, and the blank line that install added before it, go.""" + assert mavenrc.remove_block(f"export A=1\n\n{BLOCK}export B=2\n") == ( + "export A=1\nexport B=2\n", + True, + ) + + +def test_remove_block_keeps_the_lines_around_it_apart(): + """Without a blank line before the block, the line before it keeps its newline.""" + assert mavenrc.remove_block(f"export A=1\n{BLOCK}export B=2\n") == ( + "export A=1\nexport B=2\n", + True, + ) + + +def test_remove_the_only_block_returns_no_text(): + """When nothing else is in the file, the file is not needed.""" + assert mavenrc.remove_block(BLOCK) == (None, True) + + +def test_remove_block_without_a_block_changes_nothing(): + """A file without the block stays as it is.""" + assert mavenrc.remove_block("export A=1\n") == ("export A=1\n", False) + + +def _maven_opts_after_block(maven_opts: str, block: str = BLOCK) -> str: + """Run the block in the shell that the mvn script uses, and return MAVEN_OPTS.""" + script = f'MAVEN_OPTS="$1"\n{block}printf %s "$MAVEN_OPTS"\n' + result = subprocess.run( + ["sh", "-c", script, "sh", maven_opts], + capture_output=True, + text=True, + check=True, + ) + return result.stdout + + +POSIX_SHELL_ONLY = pytest.mark.skipif( + os.name == "nt", reason="The mvn script that reads ~/.mavenrc is a POSIX script." +) + + +@POSIX_SHELL_ONLY +def test_block_adds_the_class_path_to_maven_opts(): + """Without a class path in MAVEN_OPTS, the block adds one with only the JAR.""" + assert _maven_opts_after_block("-Xmx1g") == f"-Xmx1g -Dmaven.ext.class.path={JAR}" + + +@POSIX_SHELL_ONLY +@pytest.mark.parametrize("maven_opts", ["", "-Dmaven.ext.class.path=/opt/other.jar"]) +def test_block_reads_shell_characters_in_the_jar_path_as_text(maven_opts): + """The shell does not expand or run the characters of the JAR path.""" + jar = Path('/home/a$(id)`id`"d\\e/extension.jar') + block = mavenrc.render_block(jar) + + once = _maven_opts_after_block(maven_opts, block) + + assert f"-Dmaven.ext.class.path={jar}" in once + assert _maven_opts_after_block(once, block) == once + + +@POSIX_SHELL_ONLY +def test_block_ignores_the_class_path_option_inside_another_value(): + """Java reads only an option that starts a word of MAVEN_OPTS.""" + maven_opts = "-DargLine=-Dmaven.ext.class.path=/opt/test.jar -Xmx1g" + + assert _maven_opts_after_block(maven_opts) == ( + f"{maven_opts} -Dmaven.ext.class.path={JAR}" + ) + + +@POSIX_SHELL_ONLY +@pytest.mark.parametrize("separator", ["\t", "\n"]) +def test_block_finds_the_class_path_after_a_tab_or_a_new_line(separator): + """mvn splits MAVEN_OPTS at tabs and new lines too.""" + maven_opts = f"-Xmx1g{separator}-Dmaven.ext.class.path=/opt/other.jar" + + assert _maven_opts_after_block(maven_opts) == ( + f"-Xmx1g -Dmaven.ext.class.path={JAR}{os.pathsep}/opt/other.jar" + ) + + +@POSIX_SHELL_ONLY +def test_block_keeps_the_other_extensions_in_the_class_path(): + """Java uses only the last -D value, so the block adds the JAR to that value.""" + maven_opts = "-Xmx1g -Dmaven.ext.class.path=/opt/other.jar -Dquiet=true" + + assert _maven_opts_after_block(maven_opts) == ( + f"-Xmx1g -Dmaven.ext.class.path={JAR}{os.pathsep}/opt/other.jar -Dquiet=true" + ) + + +@POSIX_SHELL_ONLY +@pytest.mark.parametrize( + ("maven_opts", "expected"), + [ + ( + f"-Dmaven.ext.class.path={JAR} -Dmaven.ext.class.path=/opt/other.jar", + f"-Dmaven.ext.class.path={JAR} " + f"-Dmaven.ext.class.path={JAR}{os.pathsep}/opt/other.jar", + ), + ( + f"-Dmaven.ext.class.path=/opt/other.jar -Dnote={JAR}", + f"-Dmaven.ext.class.path={JAR}{os.pathsep}/opt/other.jar -Dnote={JAR}", + ), + ( + f"-Dmaven.ext.class.path={JAR}.old", + f"-Dmaven.ext.class.path={JAR}{os.pathsep}{JAR}.old", + ), + ], +) +def test_block_looks_for_the_jar_only_in_the_last_class_path(maven_opts, expected): + """Java uses only the last -D value, so the JAR in other text does not count.""" + assert _maven_opts_after_block(maven_opts) == expected + + +@POSIX_SHELL_ONLY +def test_block_does_not_add_the_jar_twice(): + """A nested mvn gets MAVEN_OPTS with the JAR, so the block leaves it as it is.""" + maven_opts = f"-Dmaven.ext.class.path={JAR}{os.pathsep}/opt/other.jar" + + assert _maven_opts_after_block(maven_opts) == maven_opts + + +# --------------------------------------------------------------------------- +# 4. MavenInstaller +# --------------------------------------------------------------------------- + + +def _local_extension_dir(home: Path, version: str) -> Path: + return home / ".m2" / "repository" / EXTENSION_PATH / version + + +def _user_extensions(home: Path) -> Path: + return home / ".m2" / "extensions.xml" + + +def test_maven_install_puts_the_extension_in_the_local_repository( + home, fake_repository, outside_projects +): + """Maven loads build extensions only from Maven Central or the local repository.""" + actions = MavenInstaller().install() + + version_dir = _local_extension_dir(home, "0.2.0") + assert (version_dir / "cloudsmith-maven-extension-0.2.0.jar").exists() + assert (version_dir / "cloudsmith-maven-extension-0.2.0.pom").exists() + assert any(str(version_dir) in action for action in actions) + + +def test_maven_install_turns_on_the_extension_for_every_build( + home, fake_repository, outside_projects +): + """By default, ~/.m2/extensions.xml lists the extension for Maven 3.10 and later.""" + actions = MavenInstaller().install() + + text = _user_extensions(home).read_text(encoding="utf-8") + assert "0.2.0" in text + assert not (outside_projects / ".mvn").exists() + assert not [action for action in actions if action.startswith("WARNING")] + + +def test_maven_install_warns_when_maven_ignores_user_extensions( + 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") + + actions = MavenInstaller().install() + + warnings = [action for action in actions if action.startswith("WARNING")] + assert len(warnings) == 1 + assert "3.9.11" in warnings[0] + assert "--local" in warnings[0] + assert "--mavenrc" in warnings[0] + assert _user_extensions(home).exists() + + +def test_maven_install_marks_the_files_as_installed( + home, fake_repository, outside_projects +): + """Maven does not load an extension that it records as downloaded from a repository.""" + version_dir = _local_extension_dir(home, "0.2.0") + version_dir.mkdir(parents=True) + for file_type in ("jar", "pom"): + file_name = f"cloudsmith-maven-extension-0.2.0.{file_type}" + (version_dir / file_name).write_text("from a repository", encoding="utf-8") + (version_dir / "_remote.repositories").write_text( + "#NOTE: a comment\n" + "cloudsmith-maven-extension-0.2.0.jar>cloudsmith=\n" + "cloudsmith-maven-extension-0.2.0-sources.jar>cloudsmith=\n", + encoding="utf-8", + ) + + MavenInstaller().install() + + assert (version_dir / "_remote.repositories").read_text(encoding="utf-8") == ( + "#NOTE: a comment\n" + "cloudsmith-maven-extension-0.2.0.jar>=\n" + "cloudsmith-maven-extension-0.2.0-sources.jar>cloudsmith=\n" + "cloudsmith-maven-extension-0.2.0.pom>=\n" + ) + + +def test_maven_install_local_turns_on_the_extension_in_the_project( + home, fake_repository, maven_project +): + """With local, the project's .mvn/extensions.xml lists the extension.""" + actions = MavenInstaller().install(local=True) + + extensions_file = maven_project / ".mvn" / "extensions.xml" + assert "0.2.0" in extensions_file.read_text(encoding="utf-8") + assert any(str(extensions_file) in action for action in actions) + assert not _user_extensions(home).exists() + + +def test_maven_install_local_from_a_module_uses_the_root_project( + home, fake_repository, maven_project, monkeypatch +): + """Maven reads .mvn from the nearest directory above that has one.""" + (maven_project / ".mvn").mkdir() + module = maven_project / "module" + module.mkdir() + (module / "pom.xml").write_text("", encoding="utf-8") + monkeypatch.chdir(module) + + MavenInstaller().install(local=True) + + assert (maven_project / ".mvn" / "extensions.xml").exists() + assert not (module / ".mvn").exists() + + +def test_maven_install_local_outside_a_project_fails( + home, fake_repository, outside_projects +): + """Without a pom.xml, the install stops before it changes anything.""" + with pytest.raises(ProjectNotFound, match="pom.xml"): + MavenInstaller().install(local=True) + + assert not (outside_projects / ".mvn").exists() + assert not fake_repository + + +def test_maven_uninstall_local_finds_a_project_by_its_mvn_directory( + home, outside_projects +): + """Maven finds the project root by its .mvn directory, also without a pom.xml.""" + extensions_file = outside_projects / ".mvn" / "extensions.xml" + extensions_file.parent.mkdir() + extensions_file.write_text( + f"\n{OUR_EXTENSION}\n", encoding="utf-8" + ) + + MavenInstaller().uninstall(local=True) + + assert not extensions_file.exists() + + +def test_maven_install_with_mavenrc_adds_a_block( + home, fake_repository, outside_projects +): + """--mavenrc loads the extension in Maven 3.9 and earlier, and keeps the lines there.""" + (home / ".mavenrc").write_text('export JAVA_HOME="/opt/java"\n', encoding="utf-8") + + MavenInstaller().install(mavenrc=True) + + jar = _local_extension_dir(home, "0.2.0") / "cloudsmith-maven-extension-0.2.0.jar" + text = (home / ".mavenrc").read_text(encoding="utf-8") + assert text.startswith('export JAVA_HOME="/opt/java"\n') + assert f"-Dmaven.ext.class.path={jar}" in text + # Maven 3.10 would load the extension twice from both files. + assert not _user_extensions(home).exists() + + +def test_maven_install_with_mavenrc_turns_off_the_user_extensions_file( + home, fake_repository, outside_projects +): + """Maven 3.10 loads both, so only one of them loads the extension.""" + MavenInstaller().install() + + MavenInstaller().install(mavenrc=True) + + assert not _user_extensions(home).exists() + assert mavenrc.has_block((home / ".mavenrc").read_text(encoding="utf-8")) + + +def test_maven_install_turns_off_the_mavenrc_block( + home, fake_repository, outside_projects +): + """Maven 3.10 loads both, so only one of them loads the extension.""" + (home / ".mavenrc").write_text('export JAVA_HOME="/opt/java"\n', encoding="utf-8") + MavenInstaller().install(mavenrc=True) + + MavenInstaller().install() + + assert extensions.has_extension(_user_extensions(home).read_text(encoding="utf-8")) + assert (home / ".mavenrc").read_text(encoding="utf-8") == ( + 'export JAVA_HOME="/opt/java"\n' + ) + + +def test_maven_install_dry_run_changes_nothing(home, fake_repository, maven_project): + """A dry run reports the planned changes and writes no files.""" + actions = [ + *MavenInstaller().install(dry_run=True), + *MavenInstaller().install(dry_run=True, local=True), + *MavenInstaller().install(dry_run=True, mavenrc=True), + ] + + assert not (maven_project / ".mvn").exists() + assert not (home / ".m2").exists() + assert not (home / ".mavenrc").exists() + assert all(action.startswith("would ") for action in actions) + + +def test_maven_uninstall_removes_only_what_install_added( + home, fake_repository, outside_projects +): + """Other extensions and other .mavenrc lines stay, and so does the local repository.""" + _user_extensions(home).parent.mkdir() + _user_extensions(home).write_text( + f"\n{OTHER_EXTENSION}\n", encoding="utf-8" + ) + (home / ".mavenrc").write_text("export A=1\n", encoding="utf-8") + MavenInstaller().install() + MavenInstaller().install(mavenrc=True) + + MavenInstaller().uninstall() + + assert _user_extensions(home).read_text(encoding="utf-8") == ( + f"\n{OTHER_EXTENSION}\n" + ) + assert (home / ".mavenrc").read_text(encoding="utf-8") == "export A=1\n" + assert _local_extension_dir(home, "0.2.0").exists() + + +def test_maven_install_and_uninstall_keep_a_mavenrc_that_is_not_utf_8( + home, fake_repository, outside_projects +): + """The block is added and removed, and the other bytes stay as they are.""" + original = b"# \xe9\nexport A=1\n" + (home / ".mavenrc").write_bytes(original) + + MavenInstaller().install(mavenrc=True) + assert (home / ".mavenrc").read_bytes().startswith(original) + + MavenInstaller().uninstall() + assert (home / ".mavenrc").read_bytes() == original + + +def test_maven_uninstall_local_removes_the_extension_from_the_project( + home, fake_repository, maven_project +): + """Uninstall with local removes the file that install with local made.""" + MavenInstaller().install(local=True) + + MavenInstaller().uninstall(local=True) + + assert not (maven_project / ".mvn" / "extensions.xml").exists() + + +def test_maven_status_names_the_file_that_loads_the_extension( + home, fake_repository, maven_project +): + """list shows the user-wide file first, then the file of the current project.""" + assert MavenInstaller().status() == {"launcher": None, "hosts": []} + + MavenInstaller().install(local=True) + project_file = maven_project / ".mvn" / "extensions.xml" + assert MavenInstaller().status() == {"launcher": str(project_file), "hosts": []} + + MavenInstaller().install() + assert MavenInstaller().status() == { + "launcher": str(_user_extensions(home)), + "hosts": [], + } + + +def test_maven_status_skips_a_file_that_cannot_be_decoded(home, outside_projects): + """A ~/.mavenrc that is not UTF-8 does not stop `credential-helper list`.""" + (home / ".mavenrc").write_bytes(b"# \xe9\nexport A=1\n") + + assert MavenInstaller().status() == {"launcher": None, "hosts": []} + + +def test_maven_status_skips_a_file_that_cannot_be_read( + home, fake_repository, outside_projects +): + """An unreadable candidate does not hide the next file that loads the extension.""" + MavenInstaller().install(mavenrc=True) + _user_extensions(home).mkdir(parents=True) + + assert MavenInstaller().status() == { + "launcher": str(home / ".mavenrc"), + "hosts": [], + } + + +# --------------------------------------------------------------------------- +# 5. GradleInstaller +# --------------------------------------------------------------------------- + + +def test_gradle_install_writes_an_init_script(home, fake_repository): + """The init script applies the plugin to the settings of every build.""" + actions = GradleInstaller().install() + + script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" + text = script.read_text(encoding="utf-8") + assert f'maven {{ url = uri("{maven_tools.REPOSITORY_URL}") }}' in text + assert 'classpath("io.cloudsmith:cloudsmith-gradle-plugin:0.2.0")' in text + assert "pluginManager.apply(io.cloudsmith.gradle.CloudsmithPlugin::class.java)" in ( + text + ) + assert any(str(script) in action for action in actions) + + +def test_gradle_install_uses_gradle_user_home( + home, fake_repository, tmp_path, monkeypatch +): + """GRADLE_USER_HOME moves the init.d directory.""" + monkeypatch.setenv("GRADLE_USER_HOME", str(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" + GradleInstaller().install() + assert GradleInstaller().status() == {"launcher": str(script), "hosts": []} + + GradleInstaller().uninstall() + + assert not script.exists() + assert GradleInstaller().status() == {"launcher": None, "hosts": []} + + +def test_gradle_status_ignores_an_init_script_that_install_did_not_write(home): + """Status reports only a file that uninstall can remove.""" + script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" + script.parent.mkdir(parents=True) + script.write_text("// my own settings\n", encoding="utf-8") + + assert GradleInstaller().status() == {"launcher": None, "hosts": []} + + +def test_gradle_install_upgrades_an_init_script_that_it_wrote(home, fake_repository): + """An init script for an older version gets the new version.""" + script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" + script.parent.mkdir(parents=True) + script.write_text(GradleInstaller().render("0.1.0"), encoding="utf-8") + + GradleInstaller().install() + + assert script.read_text(encoding="utf-8") == GradleInstaller().render("0.2.0") + + +def test_gradle_install_keeps_an_init_script_that_is_not_utf_8(home, fake_repository): + """A file that is not UTF-8 is not a file that install wrote.""" + script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" + script.parent.mkdir(parents=True) + script.write_bytes(b"// \xe9\n") + + with pytest.raises(ConfigFileConflict): + GradleInstaller().install() + + assert script.read_bytes() == b"// \xe9\n" + + +def test_gradle_uninstall_keeps_an_init_script_with_other_settings( + home, fake_repository +): + """Uninstall stops before it removes settings that install did not write.""" + script = home / ".gradle" / "init.d" / "cloudsmith.init.gradle.kts" + script.parent.mkdir(parents=True) + script.write_text('println("hello")\n', encoding="utf-8") + + with pytest.raises(ConfigFileConflict, match="did not write"): + GradleInstaller().uninstall() + + assert script.read_text(encoding="utf-8") == 'println("hello")\n' + + +# --------------------------------------------------------------------------- +# 6. SbtInstaller +# --------------------------------------------------------------------------- + + +def test_sbt_install_writes_a_global_plugin(home, fake_repository): + """sbt 1 loads global plugins for every build from ~/.sbt/1.0/plugins.""" + actions = SbtInstaller().install() + + plugin_file = home / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + text = plugin_file.read_text(encoding="utf-8") + assert ( + f'resolvers += "cloudsmith-maven-tools" at "{maven_tools.REPOSITORY_URL}"' + in (text) + ) + assert 'addSbtPlugin("io.cloudsmith" % "sbt-cloudsmith" % "0.2.0")' in text + assert any(str(plugin_file) in action for action in actions) + + +def test_sbt_install_local_writes_a_project_plugin( + home, fake_repository, outside_projects +): + """sbt loads every .sbt file in project/, so the build's own files stay as they are.""" + (outside_projects / "build.sbt").write_text("", encoding="utf-8") + + SbtInstaller().install(local=True) + + plugin_file = outside_projects / "project" / "cloudsmith.sbt" + text = plugin_file.read_text(encoding="utf-8") + assert 'addSbtPlugin("io.cloudsmith" % "sbt-cloudsmith" % "0.2.0")' in text + assert not (home / ".sbt").exists() + + SbtInstaller().uninstall(local=True) + + assert not plugin_file.exists() + + +def test_sbt_install_local_outside_a_project_fails( + home, fake_repository, outside_projects +): + """Without a build.sbt, the install stops before it changes anything.""" + with pytest.raises(ProjectNotFound, match="build.sbt"): + SbtInstaller().install(local=True) + + assert not (outside_projects / "project").exists() + + +@pytest.mark.parametrize( + "build_file", + [ + "main.sbt", + "project/build.properties", + "project/cloudsmith.sbt", + "project/plugins.sbt", + "project/Build.scala", + ], +) +def test_sbt_finds_a_project_without_a_build_sbt(outside_projects, build_file): + """sbt reads any .sbt file in the root and the project/ directory.""" + path = outside_projects / build_file + path.parent.mkdir(exist_ok=True) + path.write_text("", encoding="utf-8") + + assert SbtInstaller().local_path() == ( + outside_projects / "project" / "cloudsmith.sbt" + ) + + +def test_sbt_install_keeps_a_plugin_file_with_other_settings( + home, fake_repository, outside_projects +): + """Install stops before it replaces settings that it did not write.""" + (outside_projects / "build.sbt").write_text("", encoding="utf-8") + plugin_file = outside_projects / "project" / "cloudsmith.sbt" + plugin_file.parent.mkdir() + other_plugin = 'addSbtPlugin("com.example" % "sbt-other" % "1.0.0")\n' + plugin_file.write_text(other_plugin, encoding="utf-8") + + with pytest.raises(ConfigFileConflict, match="did not write"): + SbtInstaller().install(local=True) + + assert plugin_file.read_text(encoding="utf-8") == other_plugin + + +def test_sbt_install_keeps_lines_added_to_its_plugin_file(home, fake_repository): + """A line that the user adds to the file stops a later install and uninstall.""" + plugin_file = home / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + plugin_file.parent.mkdir(parents=True) + edited = SbtInstaller().render("0.1.0") + 'addSbtPlugin("a" % "b" % "1")\n' + plugin_file.write_text(edited, encoding="utf-8") + + with pytest.raises(ConfigFileConflict): + SbtInstaller().install() + with pytest.raises(ConfigFileConflict): + SbtInstaller().uninstall() + + assert plugin_file.read_text(encoding="utf-8") == edited + + +def test_sbt_uninstall_and_status(home, fake_repository): + """Uninstall removes the global plugin, and status follows it.""" + plugin_file = home / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + SbtInstaller().install() + assert SbtInstaller().status() == {"launcher": str(plugin_file), "hosts": []} + + SbtInstaller().uninstall() + + assert not plugin_file.exists() + assert SbtInstaller().status() == {"launcher": None, "hosts": []} + + +def test_sbt_status_names_the_plugin_file_of_the_project( + home, fake_repository, outside_projects +): + """After install --local, status names project/cloudsmith.sbt.""" + assert SbtInstaller().status() == {"launcher": None, "hosts": []} + (outside_projects / "build.sbt").write_text("", encoding="utf-8") + SbtInstaller().install(local=True) + + assert SbtInstaller().status() == { + "launcher": str(outside_projects / "project" / "cloudsmith.sbt"), + "hosts": [], + } + + +# --------------------------------------------------------------------------- +# 7. CLI wiring +# --------------------------------------------------------------------------- + + +def _invoke(command_name, args): + from ...cli.commands.credential_helper import manage + + command = {"install": manage.install_cmd, "uninstall": manage.uninstall_cmd} + return click.testing.CliRunner().invoke( + command[command_name], args, catch_exceptions=False + ) + + +@pytest.mark.parametrize("helper", ["maven", "gradle", "sbt"]) +def test_cli_install_runs_the_installer(helper, home, fake_repository, maven_project): + """`credential-helper install ` writes the config without credentials.""" + result = _invoke("install", [helper, "--no-discover"]) + + assert result.exit_code == 0, result.output + assert "0.2.0" in result.output + + +def test_cli_install_and_uninstall_maven_local(home, fake_repository, maven_project): + """--local reaches the installer for install and for uninstall.""" + extensions_file = maven_project / ".mvn" / "extensions.xml" + + assert _invoke("install", ["maven", "--local", "--no-discover"]).exit_code == 0 + assert extensions_file.exists() + assert _invoke("uninstall", ["maven", "--local"]).exit_code == 0 + assert not extensions_file.exists() + + +def test_cli_install_maven_with_mavenrc(home, fake_repository, outside_projects): + """--mavenrc reaches the Maven installer.""" + result = _invoke("install", ["maven", "--mavenrc", "--no-discover"]) + + assert result.exit_code == 0, result.output + assert (home / ".mavenrc").exists() + + +def test_cli_install_maven_local_outside_a_project_fails_cleanly( + home, fake_repository, outside_projects +): + """The error names the directory and the fix, without a traceback.""" + result = _invoke("install", ["maven", "--local", "--no-discover"]) + + assert result.exit_code != 0 + assert "pom.xml" in result.output + + +@pytest.mark.parametrize("command_name", ["install", "uninstall"]) +def test_cli_with_a_config_file_that_install_did_not_write_fails_cleanly( + command_name, home, fake_repository, outside_projects +): + """The error names the file, without a traceback.""" + plugin_file = home / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text("// my settings\n", encoding="utf-8") + + result = _invoke(command_name, ["sbt"]) + + assert result.exit_code == 1 + assert str(plugin_file) in result.output + + +@pytest.mark.parametrize( + ("command_name", "helper", "option"), + [ + ("install", "docker", "--local"), + ("install", "gradle", "--local"), + ("install", "sbt", "--mavenrc"), + ("uninstall", "gradle", "--local"), + ], +) +def test_cli_rejects_an_option_that_the_helper_does_not_have( + command_name, helper, option, home +): + """Click stops the command before the installer runs.""" + result = _invoke(command_name, [helper, option]) + + assert result.exit_code == 2 + assert f"{option} applies only to" in result.output + + +def test_cli_install_rejects_local_with_mavenrc(home): + """The two options set up different scopes, so only one is allowed.""" + result = _invoke("install", ["maven", "--local", "--mavenrc"]) + + assert result.exit_code == 2 + assert "--local or --mavenrc, not both" in result.output + + +@pytest.mark.parametrize("helper", ["maven", "gradle", "sbt"]) +def test_cli_install_downloads_with_the_tls_settings_of_the_command( + helper, home, outside_projects +): + """--without-ssl-verify-strict also applies to the downloads of the Maven tools.""" + from ...cli.commands.credential_helper import manage + + download_session = Mock() + sessions = [] + + def record_latest_version(artifact_path, session=None): + sessions.append(session) + return "0.2.0" + + def record_download(artifact_path, destination, session=None): + sessions.append(session) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text("content", encoding="utf-8") + + with ( + patch.object(maven_tools, "latest_version", record_latest_version), + patch.object(maven_tools, "download", record_download), + patch.object( + manage, "create_requests_session", return_value=download_session + ) as create_session, + ): + result = _invoke( + "install", [helper, "--without-ssl-verify-strict", "--no-discover"] + ) + + assert result.exit_code == 0, result.output + assert create_session.call_args.kwargs["ssl_verify_strict"] is False + # The session for the public repository gets no --api-headers. + assert "headers" not in create_session.call_args.kwargs + assert sessions + assert all(session is download_session for session in sessions) diff --git a/cloudsmith_cli/credential_helpers/gradle/__init__.py b/cloudsmith_cli/credential_helpers/gradle/__init__.py new file mode 100644 index 00000000..b84b292e --- /dev/null +++ b/cloudsmith_cli/credential_helpers/gradle/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Cloudsmith Gradle plugin.""" diff --git a/cloudsmith_cli/credential_helpers/gradle/installer.py b/cloudsmith_cli/credential_helpers/gradle/installer.py new file mode 100644 index 00000000..ec362119 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/gradle/installer.py @@ -0,0 +1,39 @@ +# Copyright 2026 Cloudsmith Ltd +"""Apply the Cloudsmith Gradle plugin to every Gradle build of the user.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from ..maven_tools import REPOSITORY_URL, ConfigFileInstaller + +INIT_SCRIPT = """\ +// Written by `cloudsmith credential-helper install gradle`. +initscript {{ + repositories {{ + maven {{ url = uri("{repository_url}") }} + }} + dependencies {{ + classpath("io.cloudsmith:cloudsmith-gradle-plugin:{version}") + }} +}} + +beforeSettings {{ + pluginManager.apply(io.cloudsmith.gradle.CloudsmithPlugin::class.java) +}} +""" + + +class GradleInstaller(ConfigFileInstaller): + name = "gradle" + summary = "Gradle plugin that gets credentials from the Cloudsmith CLI" + artifact_path = "io/cloudsmith/cloudsmith-gradle-plugin" + + 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" + + def render(self, version: str) -> str: + return INIT_SCRIPT.format(repository_url=REPOSITORY_URL, version=version) diff --git a/cloudsmith_cli/credential_helpers/maven/__init__.py b/cloudsmith_cli/credential_helpers/maven/__init__.py new file mode 100644 index 00000000..e00ccd62 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Cloudsmith Maven extension.""" diff --git a/cloudsmith_cli/credential_helpers/maven/extensions.py b/cloudsmith_cli/credential_helpers/maven/extensions.py new file mode 100644 index 00000000..31035127 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/extensions.py @@ -0,0 +1,136 @@ +# Copyright 2026 Cloudsmith Ltd +"""Add and remove the Cloudsmith extension in a Maven ``.mvn/extensions.xml`` file. + +The functions edit the text, so the rest of the file keeps its formatting. +""" + +from __future__ import annotations + +import re +from xml.etree import ElementTree + +GROUP_ID = "io.cloudsmith" +ARTIFACT_ID = "cloudsmith-maven-extension" + + +def _start_tag(name: str) -> str: + """Return a pattern for the start tag *name*, with a namespace prefix or attributes. + + The ``(?`` element. + """ + return rf"<(?:[\w.-]+:)?{name}(?:\s[^>]*)?(?" + + +def _end_tag(name: str) -> str: + """Return a pattern for the end tag *name*, with a namespace prefix.""" + return rf"" + + +# XML allows spaces and new lines before the > of a tag. +_EXTENSION_BLOCK = re.compile( + rf"{_start_tag('extension')}.*?{_end_tag('extension')}", re.DOTALL +) +_EMPTY_EXTENSIONS = re.compile(r"<((?:[\w.-]+:)?)extensions\b([^>]*?)\s*/>") +_CLOSING_EXTENSIONS = re.compile(r"") +_VERSION = re.compile(rf"{_start_tag('version')}([^<]*){_end_tag('version')}") +_COMMENT = re.compile(r"", re.DOTALL) +_GROUP_ID = re.compile( + rf"{_start_tag('groupId')}\s*{re.escape(GROUP_ID)}\s*{_end_tag('groupId')}" +) +_ARTIFACT_ID = re.compile( + rf"{_start_tag('artifactId')}\s*{re.escape(ARTIFACT_ID)}\s*{_end_tag('artifactId')}" +) + + +class ExtensionsFileError(Exception): + """The extensions.xml file has no element to add to.""" + + +def _render(version: str, prefix: str = "") -> str: + """Return the extension element, with the namespace *prefix* of .""" + return ( + f" <{prefix}extension>\n" + f" <{prefix}groupId>{GROUP_ID}\n" + f" <{prefix}artifactId>{ARTIFACT_ID}\n" + f" <{prefix}version>{version}\n" + f" \n" + ) + + +def _without_comments(text: str) -> str: + """Return *text* with each comment replaced by spaces, so the offsets stay the same.""" + return _COMMENT.sub(lambda comment: " " * len(comment.group(0)), text) + + +def _find_extension(masked: str) -> re.Match | None: + for block in _EXTENSION_BLOCK.finditer(masked): + if _GROUP_ID.search(block.group(0)) and _ARTIFACT_ID.search(block.group(0)): + return block + return None + + +def _has_extension_element(text: str) -> bool: + """Return whether *text* has an element, or is not valid XML.""" + try: + root = ElementTree.fromstring(text) + # A byte that is not UTF-8 gives a UnicodeEncodeError, which is a ValueError. + except (ElementTree.ParseError, ValueError): + return True + return any(element.tag.rpartition("}")[2] == "extension" for element in root.iter()) + + +def has_extension(text: str) -> bool: + """Return whether *text* lists the Cloudsmith extension.""" + return _find_extension(_without_comments(text)) is not None + + +def add_extension(text: str | None, version: str) -> tuple[str, bool]: + """Return *text* with the Cloudsmith extension at *version*, and whether it changed. + + A *text* of ``None`` means that there is no file yet. + """ + if text is None: + return f"\n{_render(version)}\n", True + masked = _without_comments(text) + block = _find_extension(masked) + if block: + old_version = _VERSION.search(masked, block.start(), block.end()) + if old_version is None: + raise ExtensionsFileError("The Cloudsmith extension has no .") + new_text = text[: old_version.start(1)] + version + text[old_version.end(1) :] + return new_text, new_text != text + empty = _EMPTY_EXTENSIONS.search(masked) + if empty: + prefix, attributes = empty.groups() + opened = ( + f"<{prefix}extensions{attributes}>\n" + f"{_render(version, prefix)}" + ) + return text[: empty.start()] + opened + text[empty.end() :], True + closing = _CLOSING_EXTENSIONS.search(masked) + if closing is None: + raise ExtensionsFileError("The file has no element.") + rendered = _render(version, closing.group(1)) + return text[: closing.start()] + rendered + text[closing.start() :], True + + +def remove_extension(text: str) -> tuple[str | None, bool]: + """Return *text* without the Cloudsmith extension, and whether it changed. + + The text is ``None`` when no other extension or comment is left, so the file is + not needed. + A file that is not valid XML without the extension is kept. + """ + masked = _without_comments(text) + block = _find_extension(masked) + if not block: + return text, False + # Read the blanks around the block from the real text, because the masked + # text shows a comment as blanks. + start = len(text[: block.start()].rstrip(" \t")) + end = len(text) - len(text[block.end() :].lstrip(" \t")) + end += text.startswith("\n", end) + new_text = text[:start] + text[end:] + if not _has_extension_element(new_text) and not _COMMENT.search(new_text): + return None, True + return new_text, True diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py new file mode 100644 index 00000000..e5a3886d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -0,0 +1,255 @@ +# Copyright 2026 Cloudsmith Ltd +"""Turn on the Cloudsmith Maven extension for every Maven build, or for one project. + +Maven loads a build extension only from Maven Central or the local repository, +so the installer puts the extension in the local repository first. Then it lists +the extension in one of these files: + +- ``~/.m2/extensions.xml`` (the default), which Maven 3.10 and later read. +- ``.mvn/extensions.xml`` of the current project (``local``), which Maven 3.8 + and later read. +- ``~/.mavenrc`` (``mavenrc``), which the ``mvn`` and ``mvnw`` scripts read. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + +from .. import maven_tools +from ..maven_tools import ProjectNotFound +from . import extensions +from .mavenrc import add_block, has_block, remove_block + +if TYPE_CHECKING: + from collections.abc import Callable + + import requests + +EXTENSION_PATH = f"io/cloudsmith/{extensions.ARTIFACT_ID}" +FIRST_MAVEN_WITH_USER_EXTENSIONS = (3, 10) +MAVEN_VERSION_TIMEOUT_SECONDS = 60 + + +def _project_dir() -> Path: + """Return the directory where Maven reads ``.mvn``: the nearest one that has it.""" + cwd = Path.cwd() + for directory in (cwd, *cwd.parents): + if (directory / ".mvn").is_dir(): + return directory + return cwd + + +def _project_extensions_file() -> Path: + project_dir = _project_dir() + # Maven finds the project root by its .mvn directory, whatever the POM is called. + if not (project_dir / "pom.xml").is_file() and not (project_dir / ".mvn").is_dir(): + raise ProjectNotFound( + f"No pom.xml in {project_dir}. " + "Run the command in the root directory of a Maven project." + ) + return project_dir / ".mvn" / "extensions.xml" + + +def _user_extensions_file() -> Path: + return Path.home() / ".m2" / "extensions.xml" + + +def _mavenrc_file() -> Path: + return Path.home() / ".mavenrc" + + +def _version_dir(version: str) -> Path: + return Path.home() / ".m2" / "repository" / EXTENSION_PATH / version + + +def _read(path: Path) -> str | None: + # Keep bytes that are not UTF-8, for example comments in ~/.mavenrc. + if not path.exists(): + return 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 + try: + result = subprocess.run( + [mvn, "-v"], + capture_output=True, + text=True, + timeout=MAVEN_VERSION_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + match = re.search(r"Apache Maven (\d+(?:\.\d+)+)", result.stdout) + return match.group(1) if match else None + + +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: + return [] + 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." + ) + return [warning] + + +def _mark_installed(version_dir: Path, file_names: list[str]) -> None: + """Record the files as installed, not downloaded, in ``_remote.repositories``. + + Maven does not load a build extension that this file records as downloaded + from a repository other than Maven Central. A missing file means installed. + """ + tracking_file = version_dir / "_remote.repositories" + current = _read(tracking_file) + if current is None: + return + lines = [] + for line in current.splitlines(): + name = line.split(">", 1)[0] + lines.append(f"{name}>=" if name in file_names else line) + lines += [f"{name}>=" for name in file_names if f"{name}>=" not in lines] + tracking_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _add(path: Path, edit: Callable, version: str, dry_run: bool) -> list[str]: + text, changed = edit(_read(path)) + if not changed: + return [f"{path} already loads version {version} (no change)"] + if dry_run: + return [f"would add version {version} to {path}"] + path.parent.mkdir(parents=True, exist_ok=True) + maven_tools.write_atomically(path, text.encode("utf-8", "surrogateescape")) + return [f"added version {version} to {path}"] + + +def _remove(path: Path, edit: Callable, dry_run: bool) -> list[str]: + current = _read(path) + if current is None: + return [f"{path} not found (nothing to remove)"] + text, changed = edit(current) + if not changed: + return [f"{path} does not load the extension (nothing to remove)"] + if dry_run: + return [f"would remove the extension from {path}"] + if text is None: + path.unlink() + return [f"removed {path}"] + maven_tools.write_atomically(path, text.encode("utf-8", "surrogateescape")) + return [f"removed the extension from {path}"] + + +def _turn_off(path: Path, edit: Callable, dry_run: bool) -> list[str]: + """Remove the other user-level activation, because Maven 3.10 reads both files.""" + current = _read(path) + if current is None or not edit(current)[1]: + return [] + return _remove(path, edit, dry_run) + + +class MavenInstaller: + name = "maven" + 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 + + def install( + self, + *, + dry_run: bool = False, + local: bool = False, + mavenrc: bool = False, + session: requests.Session | None = None, + **_options, + ) -> list[str]: + """Put the extension in ``~/.m2/repository`` and turn it on.""" + project_file = _project_extensions_file() if local else None + version = maven_tools.latest_version(EXTENSION_PATH, session) + 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) + + _turn_off( + _user_extensions_file(), extensions.remove_extension, dry_run + ) + ) + add_extension = partial(extensions.add_extension, version=version) + if project_file: + return actions + _add(project_file, add_extension, version, dry_run) + return ( + actions + + _add(_user_extensions_file(), add_extension, version, dry_run) + + _turn_off(_mavenrc_file(), remove_block, dry_run) + + _old_maven_warning() + ) + + def _download( + self, 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") + ] + actions = [] + for file_name in file_names: + destination = version_dir / file_name + if destination.exists(): + actions.append(f"{file_name} already in {version_dir} (no change)") + elif dry_run: + actions.append(f"would download {file_name} to {version_dir}") + else: + maven_tools.download( + f"{EXTENSION_PATH}/{version}/{file_name}", destination, session + ) + actions.append(f"downloaded {file_name} to {version_dir}") + if not dry_run: + _mark_installed(version_dir, file_names) + return actions + + def uninstall( + self, *, dry_run: bool = False, local: bool = False, **_options + ) -> list[str]: + """Remove the extension from the files that install changes. + + The local repository is shared with other projects, so it stays as it is. + """ + if local: + return _remove( + _project_extensions_file(), extensions.remove_extension, dry_run + ) + return _remove( + _user_extensions_file(), extensions.remove_extension, dry_run + ) + _remove(_mavenrc_file(), remove_block, dry_run) + + def status(self) -> dict: + candidates = ( + (_user_extensions_file(), extensions.has_extension), + (_mavenrc_file(), has_block), + (_project_dir() / ".mvn" / "extensions.xml", extensions.has_extension), + ) + for path, loads_extension in candidates: + try: + current = _read(path) + except (ValueError, OSError): + continue + if current is not None and loads_extension(current): + return {"launcher": str(path), "hosts": []} + return {"launcher": None, "hosts": []} diff --git a/cloudsmith_cli/credential_helpers/maven/mavenrc.py b/cloudsmith_cli/credential_helpers/maven/mavenrc.py new file mode 100644 index 00000000..bbc8778d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/mavenrc.py @@ -0,0 +1,86 @@ +# Copyright 2026 Cloudsmith Ltd +"""Add and remove the block that loads the Cloudsmith extension in ``~/.mavenrc``. + +The ``mvn`` and ``mvnw`` scripts read ``~/.mavenrc`` before they start Maven. The +block adds the extension to ``maven.ext.class.path``, which every Maven version +reads. The functions change only the block, so the other lines stay as they are. +""" + +from __future__ import annotations + +import os +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + +BEGIN = "# >>> cloudsmith credential-helper install maven >>>" +END = "# <<< cloudsmith credential-helper install maven <<<" + +_BLOCK_PATTERN = rf"^{re.escape(BEGIN)}\n.*?^{re.escape(END)}\n?" +_BLOCK = re.compile(_BLOCK_PATTERN, re.DOTALL | re.MULTILINE) +# Also matches the blank line that add_block puts before the block. +_BLOCK_WITH_SEPARATOR = re.compile( + rf"(?:(?<=\n)\n)?{_BLOCK_PATTERN}", re.DOTALL | re.MULTILINE +) + + +def render_block(jar: Path) -> str: + """Return the block that adds *jar* to the Maven extension class path.""" + option = "-Dmaven.ext.class.path=" + after_last = "cloudsmith_after_class_path" + # The shell expands \ " $ and ` in double quotes, so the path escapes them. + jar_text = re.sub(r'([\\"$`])', r"\\\1", str(jar)) + # mvn splits MAVEN_OPTS at spaces, tabs and new lines. The copy has only spaces. + words = "cloudsmith_maven_opts" + return ( + f"{BEGIN}\n" + "# Java uses only the last -D value, so add the JAR to that value.\n" + f"{words}=\" $(printf %s \"$MAVEN_OPTS\" | tr '\\t\\n' ' ')\"\n" + f'case "${words}" in\n' + f' *" {option}"*)\n' + f' {after_last}="${{{words}##* {option}}}"\n' + f' case "{os.pathsep}${{{after_last}%% *}}{os.pathsep}" in\n' + f' *"{os.pathsep}{jar_text}{os.pathsep}"*) ;;\n' + f' *) MAVEN_OPTS="${{{words}% {option}*}} {option}{jar_text}{os.pathsep}' + f'${after_last}"\n' + ' MAVEN_OPTS="${MAVEN_OPTS# }" ;;\n' + " esac ;;\n" + f' *) MAVEN_OPTS="$MAVEN_OPTS {option}{jar_text}" ;;\n' + "esac\n" + f"unset {words} {after_last}\n" + f"{END}\n" + ) + + +def has_block(text: str) -> bool: + """Return whether *text* has the block.""" + return _BLOCK.search(text) is not None + + +def add_block(text: str | None, jar: Path) -> tuple[str, bool]: + """Return *text* with the block for *jar*, and whether it changed. + + A *text* of ``None`` means that there is no file yet. + """ + block = render_block(jar) + if not text: + return block, True + existing = _BLOCK.search(text) + if existing: + new_text = text[: existing.start()] + block + text[existing.end() :] + return new_text, new_text != text + separator = "\n" if text.endswith("\n") else "\n\n" + return text + separator + block, True + + +def remove_block(text: str) -> tuple[str | None, bool]: + """Return *text* without the block, and whether it changed. + + The text is ``None`` when nothing else is left, so the file is not needed. + """ + if not has_block(text): + return text, False + new_text = _BLOCK_WITH_SEPARATOR.sub("", text, count=1) + return (new_text if new_text.strip() else None), True diff --git a/cloudsmith_cli/credential_helpers/maven_tools.py b/cloudsmith_cli/credential_helpers/maven_tools.py new file mode 100644 index 00000000..f4debfec --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven_tools.py @@ -0,0 +1,169 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Shared code for the installers of the Cloudsmith Maven tools. + +The Maven extension and the Gradle and sbt plugins get their credential from +`cloudsmith credential-helper generic`. They are published to a public +Cloudsmith repository. +""" + +from __future__ import annotations + +import contextlib +import os +import re +import shutil +import stat +from typing import TYPE_CHECKING +from xml.etree import ElementTree + +from ..core.session import create_requests_session + +if TYPE_CHECKING: + from pathlib import Path + + import requests + +REPOSITORY_URL = "https://dl.cloudsmith.io/public/cloudsmith/maven-tools/maven/" +TIMEOUT_SECONDS = 30 + + +class ProjectNotFound(Exception): + """The current directory is not the root directory of a project.""" + + +class ConfigFileConflict(Exception): + """The config file has content that the installer did not write.""" + + +def _get(artifact_path: str, session: requests.Session | None) -> bytes: + response = (session or create_requests_session()).get( + REPOSITORY_URL + artifact_path, timeout=TIMEOUT_SECONDS + ) + response.raise_for_status() + return response.content + + +def latest_version(artifact_path: str, session: requests.Session | None = None) -> str: + """Return the newest release of the artifact at *artifact_path*.""" + metadata = ElementTree.fromstring( + _get(f"{artifact_path}/maven-metadata.xml", session) + ) + release = metadata.findtext("versioning/release") + if not release: + raise OSError(f"{REPOSITORY_URL}{artifact_path} has no release") + return release + + +def download( + artifact_path: str, destination: Path, session: requests.Session | None = None +) -> None: + """Download the file at *artifact_path* to *destination*.""" + content = _get(artifact_path, session) + destination.parent.mkdir(parents=True, exist_ok=True) + # Install skips a file that exists, so a failed write must not leave a partial file. + write_atomically(destination, content) + + +def write_atomically(path: Path, data: bytes) -> None: + """Write *data* to *path*, so that a failed write leaves the old file as it was.""" + # Write through a symbolic link, such as a dotfile, so that the link stays. + target = path.resolve() + partial = target.with_name(f"{target.name}.{os.getpid()}.part") + # Make the copy before the data goes in, never with a wider mode than the file. + mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else 0o666 + try: + partial.touch(mode=mode, exist_ok=False) + partial.write_bytes(data) + if target.exists(): + shutil.copymode(target, partial) + partial.replace(target) + except OSError: + partial.unlink(missing_ok=True) + raise + + +class ConfigFileInstaller: + """Install a plugin with one config file that names its latest version.""" + + name = "" + summary = "" + artifact_path = "" + # The install command passes a session with its proxy and TLS settings. + downloads_files = True + + def path(self) -> Path: + """Return the config file that loads the plugin for every build.""" + raise NotImplementedError + + def local_path(self) -> Path: + """Return the config file that loads the plugin for the current project.""" + raise NotImplementedError + + def render(self, version: str) -> str: + """Return the content of the config file for *version*.""" + raise NotImplementedError + + def _written_by_install(self, text: str) -> bool: + placeholder = "\0" + template = self.render(placeholder) + before, after = (re.escape(part) for part in template.split(placeholder)) + return re.fullmatch(rf"{before}[^\s\"]+{after}", text) is not None + + def _read_own_file(self, path: Path) -> str | None: + """Return the text of *path*, or ``None`` when there is no file.""" + if not path.exists(): + return None + text = path.read_text(encoding="utf-8", errors="surrogateescape") + if not self._written_by_install(text): + raise ConfigFileConflict( + f"{path} has settings that `cloudsmith credential-helper install " + f"{self.name}` did not write. Move them to a different file, " + "and then run the command again." + ) + return text + + def install( + self, + *, + dry_run: bool = False, + local: bool = False, + session: requests.Session | None = None, + **_options, + ) -> list[str]: + path = self.local_path() if local else self.path() + 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)"] + if dry_run: + return [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}"] + + def uninstall( + self, *, dry_run: bool = False, local: bool = False, **_options + ) -> list[str]: + path = self.local_path() if local else self.path() + if self._read_own_file(path) is None: + return [f"{path} not found (nothing to remove)"] + if dry_run: + return [f"would remove {path}"] + path.unlink() + return [f"removed {path}"] + + def status(self) -> dict: + paths = [self.path()] + # Gradle has no local config, and the current directory is often not a project. + with contextlib.suppress(NotImplementedError, ProjectNotFound): + paths.append(self.local_path()) + path = next((path for path in paths if self._is_own_file(path)), None) + return {"launcher": str(path) if path else None, "hosts": []} + + def _is_own_file(self, path: Path) -> bool: + """Return whether *path* is a file that uninstall can remove.""" + try: + return self._read_own_file(path) is not None + except (ConfigFileConflict, OSError): + return False diff --git a/cloudsmith_cli/credential_helpers/sbt/__init__.py b/cloudsmith_cli/credential_helpers/sbt/__init__.py new file mode 100644 index 00000000..77e03164 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/sbt/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Cloudsmith sbt plugin.""" diff --git a/cloudsmith_cli/credential_helpers/sbt/installer.py b/cloudsmith_cli/credential_helpers/sbt/installer.py new file mode 100644 index 00000000..c2d13010 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/sbt/installer.py @@ -0,0 +1,46 @@ +# Copyright 2026 Cloudsmith Ltd +"""Add the Cloudsmith sbt plugin to every sbt 1 build of the user.""" + +from __future__ import annotations + +from pathlib import Path + +from ..maven_tools import REPOSITORY_URL, ConfigFileInstaller, ProjectNotFound + +GLOBAL_PLUGIN = """\ +// Written by `cloudsmith credential-helper install sbt`. +resolvers += "cloudsmith-maven-tools" at "{repository_url}" +addSbtPlugin("io.cloudsmith" % "sbt-cloudsmith" % "{version}") +""" + + +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" + + def path(self) -> Path: + return Path.home() / ".sbt" / "1.0" / "plugins" / "cloudsmith.sbt" + + def local_path(self) -> Path: + # sbt loads every .sbt file in project/, so the build's own files stay as they are. + project_dir = Path.cwd() + meta_dir = project_dir / "project" + plugin_file = meta_dir / "cloudsmith.sbt" + # sbt reads any .sbt file in the root, and a build can have only project/. + build_files = [ + *project_dir.glob("*.sbt"), + meta_dir / "build.properties", + *meta_dir.glob("*.sbt"), + *meta_dir.glob("*.scala"), + ] + is_project = any(path.is_file() for path in build_files) + if not is_project: + raise ProjectNotFound( + f"No build.sbt in {project_dir}. " + "Run the command in the root directory of an sbt project." + ) + return plugin_file + + def render(self, version: str) -> str: + return GLOBAL_PLUGIN.format(repository_url=REPOSITORY_URL, version=version)