diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb31916..4b8299bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Added an PNPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install pnpm` installs an `pnpm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall pnpm` and `cloudsmith credential-helper list`. + ## [1.24.0] - 2026-08-18 ### Added diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 91d12bb9..c1bf2db5 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -12,6 +12,7 @@ from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .pnpm import pnpm as pnpm_cmd @click.group() @@ -27,11 +28,18 @@ def credential_helper(): # Install Docker credential helper $ cloudsmith credential-helper install docker + # Install pnpm credential helper + $ cloudsmith credential-helper install pnpm + # Test Docker credential helper directly $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker + + # Test pnpm credential helper directly + $ cloudsmith credential-helper pnpm npm.cloudsmith.io """ +credential_helper.add_command(pnpm_cmd, name="pnpm") credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(generic_cmd, name="generic") credential_helper.add_command(install_cmd, name="install") diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 35cdc825..2eaa94ef 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -12,6 +12,9 @@ import click +from cloudsmith_cli.credential_helpers.generic import PartialInstallError +from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller + from ....credential_helpers.docker.installer import DockerInstaller from ... import utils from ...decorators import ( @@ -27,6 +30,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "pnpm": PNPMInstaller, } @@ -40,7 +44,7 @@ def _get_installer(name: str): Returns ------- - DockerInstaller + BaseInstaller An instance of the appropriate installer class. Raises @@ -85,7 +89,7 @@ def _get_installer(name: str): "--no-discover", is_flag=True, default=False, - help="Disable automatic discovery of custom Docker domains.", + help="Disable automatic discovery of custom domains.", ) @click.option( "--refresh", @@ -110,25 +114,30 @@ def install_cmd( ) -> None: """Install a credential helper launcher and configure the package manager. - HELPER is the name of the credential helper to install (e.g. ``docker``). + HELPER is the name of the credential helper to install (e.g. ``docker``, ``pnpm``). + + Important for pnpm: The tokenHelper directive is only honored in the + user-level ~/.npmrc file, not in a project-level .npmrc. This is a pnpm + security restriction. The absolute path to the launcher is automatically + calculated and configured. Examples: \b - # Install Docker credential helper - $ cloudsmith credential-helper install docker + # Install credential helper + $ cloudsmith credential-helper install HELPER \b # Install with a custom domain - $ cloudsmith credential-helper install docker --domain my.registry.example.com + $ cloudsmith credential-helper install HELPER --domain my.registry.example.com \b # Preview without making changes - $ cloudsmith credential-helper install docker --dry-run + $ cloudsmith credential-helper install HELPER --dry-run \b # Disable automatic custom-domain discovery - $ cloudsmith credential-helper install docker --no-discover + $ cloudsmith credential-helper install HELPER --no-discover """ installer = _get_installer(helper) try: @@ -146,6 +155,11 @@ def install_cmd( raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" ) + except PartialInstallError as exc: + actions = exc.actions + ec = exc.exit_code + else: + ec = 0 use_stderr = utils.should_use_stderr(opts) warnings = [a for a in actions if a.startswith("WARNING")] @@ -157,7 +171,7 @@ def install_cmd( "warnings": warnings, } if utils.maybe_print_as_json(opts, data): - return + sys.exit(ec) if dry_run: click.echo("Dry run — no changes will be made:", err=use_stderr) @@ -165,6 +179,7 @@ def install_cmd( click.echo(f" {action}" if dry_run else action, err=use_stderr) for warning in warnings: click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow") + sys.exit(ec) # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/cli/commands/credential_helper/pnpm.py b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py new file mode 100644 index 00000000..55654c7a --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/pnpm.py @@ -0,0 +1,51 @@ +# Copyright 2026 Cloudsmith Ltd +""" +pnpm credential helper command. + +Implements the pnpm credential helper protocol for Cloudsmith registries. +""" + +import sys + +import click + +from ....credential_helpers.pnpm import execute +from ...decorators import common_api_auth_options, resolve_credentials + + +@click.command() +@click.argument("repo", required=False, default="npm.cloudsmith.io") +@common_api_auth_options +@resolve_credentials +def pnpm(opts, repo): + """ + Input (arg, optional): + Server URL as plain text (e.g. "npm.cloudsmith.io") + + Output (stdout): + Text: + + \b + Exit codes: + 0: Success + 1: Error (no credentials available, not a Cloudsmith registry, etc.) + + \b + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + CLOUDSMITH_ORG: Organisation slug (required for custom domain support) + """ + + exit_code, stdout, stderr = execute( + repo, + credential=opts.credential, + api_host=opts.api_host, + org=opts.org, + ) + + if stdout is not None: + click.echo(stdout, nl=False) + if stderr is not None: + click.echo(stderr, err=True) + + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index 62ffc723..bc14fabd 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -12,6 +12,10 @@ import click.testing import pytest +from _pytest.monkeypatch import MonkeyPatch + +from cloudsmith_cli.credential_helpers.generic import PartialInstallError +from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller from ....core.credentials.models import CredentialResult from ....credential_helpers.default_domains import DomainType @@ -43,21 +47,37 @@ def runner(): @pytest.mark.parametrize( - "windows,expected_name,expected_content", + "windows,format,expected_name,expected_content", [ ( False, + "docker", "docker-credential-cloudsmith", '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n', ), ( True, + "docker", "docker-credential-cloudsmith.cmd", "@echo off\r\ncloudsmith credential-helper docker %*\r\n", ), + ( + False, + "pnpm", + "pnpm-credential-cloudsmith", + '#!/bin/sh\nexec cloudsmith credential-helper pnpm "$@"\n', + ), + ( + True, + "pnpm", + "pnpm-credential-cloudsmith.cmd", + "@echo off\r\ncloudsmith credential-helper pnpm %*\r\n", + ), ], ) -def test_launcher_filename_and_content(windows, expected_name, expected_content): +def test_launcher_filename_and_content( + windows, format, expected_name, expected_content +): """Per-platform launcher name + body — guards the exact Windows .cmd bytes. Parameterised on ``windows`` rather than patching ``os.name`` so no @@ -65,11 +85,11 @@ def test_launcher_filename_and_content(windows, expected_name, expected_content) ``NotImplementedError`` on Python < 3.12). """ assert ( - _launcher_filename("docker-credential-cloudsmith", windows=windows) + _launcher_filename(f"{format}-credential-cloudsmith", windows=windows) == expected_name ) assert ( - _launcher_content("cloudsmith credential-helper docker", windows=windows) + _launcher_content(f"cloudsmith credential-helper {format}", windows=windows) == expected_content ) @@ -79,30 +99,46 @@ def test_launcher_filename_and_content(windows, expected_name, expected_content) # --------------------------------------------------------------------------- -def test_write_launcher_writes_executable_script(tmp_path): +@pytest.mark.parametrize( + "format", + [ + "docker", + "pnpm", + ], +) +def test_write_launcher_writes_executable_script(format, tmp_path): """write_launcher writes the shim with content + 0o755 on the host platform.""" dest = write_launcher( tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", + f"{format}-credential-cloudsmith", + f"cloudsmith credential-helper {format}", ) - expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' + expected = f'#!/bin/sh\nexec cloudsmith credential-helper {format} "$@"\n' assert dest.read_text(encoding="utf-8") == expected assert stat.S_IMODE(dest.stat().st_mode) == 0o755 -def test_remove_launcher(tmp_path): +@pytest.mark.parametrize( + "format", + [ + "docker", + "pnpm", + ], +) +def test_remove_launcher(format, tmp_path): """remove_launcher returns True + file gone when present, False when absent.""" write_launcher( tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", + f"{format}-credential-cloudsmith", + f"cloudsmith credential-helper {format}", ) - assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is True - assert not (tmp_path / "docker-credential-cloudsmith").exists() + launcher = f"{format}-credential-cloudsmith" + assert (tmp_path / launcher).exists() + assert remove_launcher(tmp_path, launcher) is True + assert not (tmp_path / launcher).exists() # Second call: file is gone now - assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is False + assert remove_launcher(tmp_path, launcher) is False # --------------------------------------------------------------------------- @@ -110,9 +146,64 @@ def test_remove_launcher(tmp_path): # --------------------------------------------------------------------------- -def test_resolve_bin_dir_override(tmp_path): - """An explicit override is returned verbatim.""" - assert resolve_bin_dir(str(tmp_path)) == tmp_path +def test_resolve_bin_dir_override_absolute(tmp_path): + """An absolute path override is returned as an absolute path.""" + absolute_path = tmp_path.resolve() + assert resolve_bin_dir(str(absolute_path)) == absolute_path + + +def test_resolve_bin_dir_override_relative_simple(tmp_path, monkeypatch): + """A relative path override is resolved to an absolute path. + + If we're in /path/from/root/to and give 'launcher/bin', it returns + /path/from/root/to/launcher/bin. + """ + monkeypatch.chdir(tmp_path) + relative_override = "launcher/bin" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct: cwd / relative_override + expected = tmp_path / relative_override + assert result == expected + + +def test_resolve_bin_dir_override_relative_parent(tmp_path, monkeypatch): + """A relative path with parent directory references is resolved correctly. + + If we're in /path/from/root/to/randomdir and give '../launcher/bin', + it returns /path/from/root/to/launcher/bin. + """ + # Create subdirectory structure + random_dir = tmp_path / "randomdir" + random_dir.mkdir() + + monkeypatch.chdir(random_dir) + relative_override = "../launcher/bin" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct: (cwd / parent / launcher / bin) + expected = tmp_path / "launcher" / "bin" + assert result == expected + + +def test_resolve_bin_dir_override_relative_current_dir(tmp_path, monkeypatch): + """A relative path starting with './' is resolved correctly.""" + monkeypatch.chdir(tmp_path) + relative_override = "./locally-scoped-dir" + result = resolve_bin_dir(relative_override) + + # Result should be absolute + assert result.is_absolute() + + # Result should be correct + expected = tmp_path / "locally-scoped-dir" + assert result == expected @pytest.mark.parametrize( @@ -190,6 +281,28 @@ def test_docker_installer_install(tmp_path, monkeypatch): assert (bin_dir / "docker-credential-cloudsmith").exists() +def test_pnpm_installer_install(tmp_path: Path, monkeypatch: MonkeyPatch): + """install sets default+extra domains, preserves foreign entries, writes the launcher.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + # Seed a config with foreign data that must be preserved + npm_path.write_text("//registry.npmjs.org/:_authToken=abc123") + + installer = PNPMInstaller() + installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) + + assert ( + npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.registry.example.com/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" + ) + # Launcher written + assert (bin_dir / "pnpm-credential-cloudsmith").exists() + + # --------------------------------------------------------------------------- # 6. install --dry-run # --------------------------------------------------------------------------- @@ -210,6 +323,58 @@ def test_docker_installer_dry_run(tmp_path, monkeypatch): assert any("docker.cloudsmith.io" in a for a in actions) +def test_pnpm_installer_dry_run(tmp_path, monkeypatch): + """dry_run=True: no launcher written, config.json absent, returns planned strings.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = PNPMInstaller() + actions = installer.install(bin_dir=str(bin_dir), dry_run=True) + + assert not (bin_dir / "pnpm-credential-cloudsmith").exists() + assert not npm_path.exists() + assert any("would write launcher" in a for a in actions) + assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) + + +def test_pnpm_installer_dry_run_updates_existing_tokenhelper(tmp_path, monkeypatch): + """dry_run with existing tokenHelper reports update same as adding new entry. + + When re-installing with a different bin_dir, the dry-run should report + the update using the same "would set" format as if adding a new entry. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + old_bin_dir = tmp_path / "old_bin" + new_bin_dir = tmp_path / "new_bin" + + # Seed config with existing tokenHelper entry + npm_path.write_text( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" + ) + + # Re-install with different bin_dir in dry-run mode + installer = PNPMInstaller() + actions = installer.install( + bin_dir=str(new_bin_dir), domains=("npm.cloudsmith.io",), dry_run=True + ) + + # Verify no files were created/modified + assert not (new_bin_dir / "pnpm-credential-cloudsmith").exists() + assert npm_path.read_text().startswith( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}" + ) + + # Verify dry-run reported the update as a "would set" action + # Same format as if it were being added for the first time + assert any("would write launcher" in a for a in actions) + assert any(a.startswith("would set //npm.cloudsmith.io/") for a in actions) + assert any(str(new_bin_dir) in a for a in actions), ( + "Actions should mention the new bin_dir path" + ) + + # --------------------------------------------------------------------------- # 7. install idempotent # --------------------------------------------------------------------------- @@ -232,6 +397,76 @@ def test_docker_installer_idempotent(tmp_path, monkeypatch): assert any("already up to date" in a for a in actions) +def test_pnpm_installer_idempotent(tmp_path, monkeypatch): + """Second install run reports no change (config mtime unchanged, 'already up to date').""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = PNPMInstaller() + installer.install(bin_dir=str(bin_dir)) + + mtime_before = npm_path.stat().st_mtime + actions = installer.install(bin_dir=str(bin_dir)) + mtime_after = npm_path.stat().st_mtime + + assert mtime_before == mtime_after + assert any("already up to date" in a for a in actions) + + +def test_pnpm_installer_updates_tokenhelper_with_different_bin_dir( + tmp_path: Path, monkeypatch: MonkeyPatch +): + """Re-installing with a different bin_dir updates the tokenHelper path. + + When an entry with tokenHelper already exists for a domain, calling install + again with a different bin_dir should overwrite the old launcher path with + the new one. Verifies that only the matching domain entries are updated. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + old_bin_dir = tmp_path / "old_bin" + new_bin_dir = tmp_path / "new_bin" + + # Seed config with existing tokenHelper entries for domains that will be + # re-installed, plus one unrelated entry + npm_path.write_text( + f"//npm.cloudsmith.io/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={old_bin_dir}/pnpm-credential-cloudsmith\n" + "//registry.npmjs.org/:_authToken=abc123" + ) + + # Re-install with new bin_dir for the same domains that already exist + installer = PNPMInstaller() + installer.install(bin_dir=str(new_bin_dir), domains=("my.custom.domain",)) + + content = npm_path.read_text() + + # Verify old paths are replaced with new ones for managed domains + assert ( + f"//npm.cloudsmith.io/:tokenHelper={new_bin_dir}/pnpm-credential-cloudsmith" + in content + ), "npm.cloudsmith.io should be updated to new_bin_dir" + + assert ( + f"//my.custom.domain/:tokenHelper={new_bin_dir}/pnpm-credential-cloudsmith" + in content + ), "my.custom.domain should be updated to new_bin_dir" + + # Verify old bin_dir paths are gone + assert f"{old_bin_dir}" not in content, ( + "All old bin_dir paths should be replaced with new_bin_dir" + ) + + # Verify non-cloudsmith entries are preserved + assert "//registry.npmjs.org/:_authToken=abc123" in content, ( + "Non-cloudsmith entries should be preserved" + ) + + # Verify launcher exists in new location + assert (new_bin_dir / "pnpm-credential-cloudsmith").exists() + + # --------------------------------------------------------------------------- # 8. uninstall # --------------------------------------------------------------------------- @@ -277,6 +512,34 @@ def test_docker_installer_uninstall(tmp_path, monkeypatch): assert not launcher.exists() +def test_pnpm_installer_uninstall(tmp_path: Path, monkeypatch): + """uninstall removes only cloudsmith entries (foreign kept) + removes launcher. + + Also verifies --bin-dir: install to custom dir, uninstall with same --bin-dir removes it. + """ + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + custom_bin_dir = tmp_path / "custom_bin" + + # Install to a custom bin dir + installer = PNPMInstaller() + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={custom_bin_dir}/pnpm-credential-cloudsmith\n" + f"//my.custom.domain/:tokenHelper={custom_bin_dir}/pnpm-credential-cloudsmith" + ) + # Install launcher to custom_bin_dir + installer.install(bin_dir=str(custom_bin_dir)) + launcher = custom_bin_dir / "pnpm-credential-cloudsmith" + assert launcher.exists(), "Precondition: launcher must exist after install" + + # Uninstall — removes cloudsmith keys and launcher + installer.uninstall(bin_dir=str(custom_bin_dir)) + + assert npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123" + assert not launcher.exists() + + # --------------------------------------------------------------------------- # 9. DockerInstaller.status — str-not-Path guard # --------------------------------------------------------------------------- @@ -320,6 +583,44 @@ def test_docker_installer_status_type_contract(tmp_path, monkeypatch): assert not isinstance(launcher, Path) +def test_pnpm_installer_status_type_contract(tmp_path: Path, monkeypatch: MonkeyPatch): + """status()['launcher'] is str when installed and None when not — never a Path. + + Retained guard: the -F json Path-serialization regression. + """ + npm_path = tmp_path / ".npm" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + installer = PNPMInstaller() + + # Before install: launcher is None + with patch( + "cloudsmith_cli.credential_helpers.pnpm.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result_before = installer.status() + + assert result_before["launcher"] is None + assert not isinstance(result_before["launcher"], Path) + + # After install: launcher is a non-None str + installer.install(bin_dir=str(bin_dir)) + with patch( + "cloudsmith_cli.credential_helpers.pnpm.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result_after = installer.status() + + launcher = result_after["launcher"] + assert launcher is not None + assert isinstance(launcher, str), ( + f"status()['launcher'] must be str, got {type(launcher).__name__!r}" + ) + assert launcher.endswith("pnpm-credential-cloudsmith") + assert not isinstance(launcher, Path) + + # --------------------------------------------------------------------------- # 10. autodiscovery # --------------------------------------------------------------------------- @@ -549,34 +850,50 @@ def test_manage_cli_unknown_helper_exits_nonzero(runner): # --------------------------------------------------------------------------- -def test_manage_cli_dry_run_exits_0(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize( + "helper", + [ + "docker", + "pnpm", + ], +) +def test_manage_cli_dry_run_exits_0(helper, runner, tmp_path, monkeypatch): """install docker --no-discover --dry-run exits 0.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd result = runner.invoke( install_cmd, - ["docker", "--no-discover", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + [helper, "--no-discover", "--dry-run", "--bin-dir", str(tmp_path / "bin")], ) assert result.exit_code == 0, result.output assert "would" in result.output.lower() or "dry run" in result.output.lower() +@pytest.mark.parametrize( + "format,installer", + [ + ("docker", DockerInstaller), + ("pnpm", PNPMInstaller), + ], +) def test_manage_cli_passes_resolved_credential_to_installer( - runner, tmp_path, monkeypatch + format, installer, runner, tmp_path, monkeypatch ): """install hands the resolved CredentialResult to the installer intact.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd - with patch.object(DockerInstaller, "install", return_value=[]) as mock_install: + with patch.object(installer, "install", return_value=[]) as mock_install: result = runner.invoke( install_cmd, [ - "docker", + format, "--no-discover", "--bin-dir", str(tmp_path / "bin"), @@ -596,10 +913,9 @@ def test_manage_cli_passes_resolved_credential_to_installer( # --------------------------------------------------------------------------- -def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): +def test_docker_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): """install returns a WARNING action when target bin_dir is not on PATH.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) bin_dir = tmp_path / "bin" monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") @@ -611,6 +927,19 @@ def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): assert any("PATH" in a for a in warning_actions) +def test_pnpm_no_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): + """install returns a WARNING action when target bin_dir is not on PATH.""" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + + installer = PNPMInstaller() + actions = installer.install(bin_dir=str(bin_dir)) + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert not warning_actions, f"Expected no WARNING action, got: {actions}" + + # --------------------------------------------------------------------------- # 15. Unwritable dir → clean ClickException (no raw traceback) # --------------------------------------------------------------------------- @@ -620,9 +949,19 @@ def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): os.name != "posix" or (hasattr(os, "geteuid") and os.geteuid() == 0), reason="permission test only meaningful on POSIX as non-root", ) -def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize( + "format", + [ + "docker", + "pnpm", + ], +) +def test_unwritable_bin_dir_gives_click_exception( + format, runner, tmp_path, monkeypatch +): """install with an unwritable --bin-dir exits non-zero as ClickException/SystemExit, not raw OSError.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd @@ -631,7 +970,7 @@ def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch) ro_dir.chmod(0o500) try: - result = runner.invoke(install_cmd, ["docker", "--bin-dir", str(ro_dir)]) + result = runner.invoke(install_cmd, [format, "--bin-dir", str(ro_dir)]) finally: ro_dir.chmod(0o700) @@ -646,16 +985,6 @@ def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch) # --------------------------------------------------------------------------- -_STUB_STATUS = { - "launcher": "/some/bin/docker-credential-cloudsmith", - "hosts": ["docker.cloudsmith.io"], -} - - -def _stub_status_fn(_self): - return _STUB_STATUS - - @pytest.mark.parametrize( "cmd_name,cli_args,expected_helper,expect_dry_run_key", [ @@ -674,6 +1003,20 @@ def _stub_status_fn(_self): "docker", True, ), + ( + "install_cmd", + [ + "pnpm", + "--dry-run", + "--no-discover", + "--bin-dir", + "{bin_dir}", + "-F", + "json", + ], + "pnpm", + True, + ), # uninstall dry-run with -F json ( "uninstall_cmd", @@ -681,6 +1024,12 @@ def _stub_status_fn(_self): "docker", True, ), + ( + "uninstall_cmd", + ["pnpm", "--dry-run", "-F", "json"], + "pnpm", + True, + ), # list with -F json ( "list_cmd", @@ -688,6 +1037,12 @@ def _stub_status_fn(_self): "docker", False, ), + ( + "list_cmd", + ["-F", "json"], + "pnpm", + False, + ), ], ) def test_output_format_json( @@ -703,8 +1058,23 @@ def test_output_format_json( Retained guard: list -F json serialises a launcher path (str), not a Path object. """ + + def _docker_stub_status_fn(_self): + return { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], + } + + def _pnpm_stub_status_fn(_self): + return { + "launcher": "/some/bin/pnpm-credential-cloudsmith", + "hosts": ["npm.cloudsmith.io"], + } + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", _stub_status_fn) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) + monkeypatch.setattr(DockerInstaller, "status", _docker_stub_status_fn) + monkeypatch.setattr(PNPMInstaller, "status", _pnpm_stub_status_fn) from ....cli.commands.credential_helper import manage as manage_mod @@ -727,7 +1097,7 @@ def test_output_format_json( assert isinstance(data, list) entry = next(e for e in data if e["helper"] == expected_helper) assert "launcher" in entry - assert entry["launcher"] == "/some/bin/docker-credential-cloudsmith" + assert entry["launcher"] == f"/some/bin/{expected_helper}-credential-cloudsmith" assert "hosts" in entry else: assert data["helper"] == expected_helper @@ -737,15 +1107,17 @@ def test_output_format_json( assert data["dry_run"] is True -def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): +@pytest.mark.parametrize("helper", ["docker", "pnpm"]) +def test_output_format_default_shows_human_text(helper, runner, tmp_path, monkeypatch): """Default (no -F) install dry-run shows human-readable text, not raw JSON.""" monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(tmp_path / ".npmrc")) from ....cli.commands.credential_helper.manage import install_cmd result = runner.invoke( install_cmd, - ["docker", "--dry-run", "--no-discover", "--bin-dir", str(tmp_path / "bin")], + [helper, "--dry-run", "--no-discover", "--bin-dir", str(tmp_path / "bin")], catch_exceptions=False, ) @@ -758,7 +1130,7 @@ def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): # --------------------------------------------------------------------------- -def test_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): +def test_docker_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" docker_dir = tmp_path / ".docker" docker_dir.mkdir(parents=True) @@ -780,7 +1152,30 @@ def test_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" -def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): +def test_pnpm_install_coerces_malformed_cred_helpers(tmp_path: Path, monkeypatch): + """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (list instead of dict) + npm_path.write_text( + "badtext\n//registry.npmjs.org/:_authToken=abc123\nmorebadtext\n" + ) + + installer = PNPMInstaller() + # Must not raise + installer.install(bin_dir=str(bin_dir), discover=False) + + assert ( + npm_path.read_text() == "badtext\n" + "//registry.npmjs.org/:_authToken=abc123\n" + "morebadtext\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" + ) + + +def test_docker_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" docker_dir = tmp_path / ".docker" docker_dir.mkdir(parents=True) @@ -798,6 +1193,144 @@ def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): installer.uninstall(bin_dir=str(bin_dir)) +def test_pnpm_uninstall_tolerates_malformed_cred_helpers(tmp_path: Path, monkeypatch): + """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "badtext\n" + "//registry.npmjs.org/:_authToken=abc123\n" + "morebadtext\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" + ) + + installer = PNPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == "badtext\n//registry.npmjs.org/:_authToken=abc123\nmorebadtext" + ) + + +@pytest.mark.parametrize("char", [";", "#"]) +def test_pnpm_uninstall_tolerates_comments(char, tmp_path: Path, monkeypatch): + """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith{char}wowcommented" + ) + + installer = PNPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert npm_path.read_text() == "//registry.npmjs.org/:_authToken=abc123" + + +def test_pnpm_uninstall_tolerates_leading_whitespace(tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + "//registry.npmjs.org/:_authToken=abc123\n" + f" //npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" + "//unrelated.registry/:_authToken=abc123" + ) + + installer = PNPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == "//registry.npmjs.org/:_authToken=abc123\n//unrelated.registry/:_authToken=abc123" + ) + + +def test_pnpm_uninstall_preserves_leading_whitespace(tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + npm_path.write_text( + " //registry.npmjs.org/:_authToken=abc123\n" + f"//npm.cloudsmith.io/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith\n" + "//unrelated.registry/:_authToken=abc123" + ) + + installer = PNPMInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) + + assert ( + npm_path.read_text() + == " //registry.npmjs.org/:_authToken=abc123\n//unrelated.registry/:_authToken=abc123" + ) + + +@pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) +def test_pnpm_install_warn_on_auth_configured(kind, tmp_path: Path, monkeypatch): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") + + installer = PNPMInstaller() + with pytest.raises(PartialInstallError) as e: + installer.install(bin_dir=str(bin_dir), discover=False) + assert e.value.exit_code == 1 + actions = e.value.actions + + # file was untouched + assert npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token" + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + + +@pytest.mark.parametrize("kind", ["_auth", "_authToken", "_password"]) +def test_pnpm_install_warn_on_auth_configured_partial_write( + kind, tmp_path: Path, monkeypatch +): + npm_path = tmp_path / ".npmrc" + monkeypatch.setenv("NPM_CONFIG_USERCONFIG", str(npm_path)) + bin_dir = tmp_path / "bin" + + npm_path.write_text(f"//npm.cloudsmith.io/:{kind}=token") + + installer = PNPMInstaller() + with pytest.raises(PartialInstallError) as e: + installer.install( + bin_dir=str(bin_dir), discover=False, domains=("my.registry.example.com",) + ) + + assert e.value.exit_code == 1 + actions = e.value.actions + + # file was partially written to + assert ( + npm_path.read_text() == f"//npm.cloudsmith.io/:{kind}=token\n" + f"//my.registry.example.com/:tokenHelper={bin_dir}/pnpm-credential-cloudsmith" + ) + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + + # --------------------------------------------------------------------------- # 18. frozen-binary launcher target (PyInstaller standalone) # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/generic.py b/cloudsmith_cli/credential_helpers/generic.py index cbb2de05..98e6465c 100644 --- a/cloudsmith_cli/credential_helpers/generic.py +++ b/cloudsmith_cli/credential_helpers/generic.py @@ -20,6 +20,22 @@ ) +class PartialInstallError(Exception): + """Raised when the installation partially fails, but scheduled post-install reporting actions still need to be executed.""" + + def __init__(self, actions, exit_code=1): + self._actions = actions + self._exit_code = exit_code + + @property + def actions(self): + return self._actions + + @property + def exit_code(self): + return self._exit_code + + def build_response(credential): """ Build the versioned credential document. diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index acf978ef..2886879e 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -54,7 +54,7 @@ def _user_bin_dir(windows: bool) -> Path: return Path.home() / ".local" / "bin" -def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: +def write_launcher(bin_dir: Path, name: str, target_cmd: str, dry_run=False) -> Path: """Write a launcher script for *name* in *bin_dir* that execs *target_cmd*. Parameters @@ -73,6 +73,11 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: The path of the written file. """ windows = _is_windows() + if dry_run: + if windows: + return bin_dir / f"{name}.cmd" + else: + return bin_dir / name bin_dir = Path(bin_dir) bin_dir.mkdir(parents=True, exist_ok=True) @@ -86,7 +91,7 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: return dest -def remove_launcher(bin_dir: Path, name: str) -> bool: +def remove_launcher(bin_dir: Path, name: str, dry_run=False) -> bool: """Remove a launcher previously created by :func:`write_launcher`. Parameters @@ -104,7 +109,8 @@ def remove_launcher(bin_dir: Path, name: str) -> bool: target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows()) if target.exists(): - target.unlink() + if not dry_run: + target.unlink() return True return False @@ -114,9 +120,10 @@ def resolve_bin_dir(override: str | None = None) -> Path: Resolution order ---------------- - 1. *override* → ``Path(override)``. + 1. *override* → resolved to an absolute path (relative paths are resolved + against the current working directory). 2. The directory of the running ``cloudsmith`` executable — if that - directory is writable. + directory is writable. 3. The user-local bin directory (see :func:`_user_bin_dir`). The chosen directory is **not** created here; that happens when the @@ -126,14 +133,17 @@ def resolve_bin_dir(override: str | None = None) -> Path: ---------- override: Explicit path supplied by the caller (e.g. ``--bin-dir`` CLI option). + Both relative and absolute paths are supported; relative paths are + resolved against the current working directory, ensuring the returned + path is always absolute. Returns ------- Path - The resolved directory. + The resolved directory as an absolute path. """ if override is not None: - return Path(override) + return Path(override).resolve() # Option 2: beside the running cloudsmith binary (if writable) cloudsmith_path = shutil.which("cloudsmith") @@ -143,10 +153,10 @@ def resolve_bin_dir(override: str | None = None) -> Path: candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0]))) if os.access(candidate, os.W_OK | os.X_OK): - return candidate + return candidate.resolve() # Option 3: user-local bin - return _user_bin_dir(_is_windows()) + return _user_bin_dir(_is_windows()).resolve() def is_on_path(directory: Path) -> bool: diff --git a/cloudsmith_cli/credential_helpers/pnpm/__init__.py b/cloudsmith_cli/credential_helpers/pnpm/__init__.py new file mode 100644 index 00000000..cbf2f40d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd +from .runtime import execute, get_pnpm_credentials + +__all__ = ["execute", "get_pnpm_credentials"] diff --git a/cloudsmith_cli/credential_helpers/pnpm/installer.py b/cloudsmith_cli/credential_helpers/pnpm/installer.py new file mode 100644 index 00000000..f45cea8d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/installer.py @@ -0,0 +1,264 @@ +# Copyright 2026 Cloudsmith Ltd +import logging +import os +import sys +from pathlib import Path + +from cloudsmith_cli.credential_helpers.backends import BackendKind +from cloudsmith_cli.credential_helpers.custom_domains import get_format_domains +from cloudsmith_cli.credential_helpers.generic import PartialInstallError +from cloudsmith_cli.credential_helpers.launchers import ( + remove_launcher, + resolve_bin_dir, + write_launcher, +) +from cloudsmith_cli.credential_helpers.pnpm.rc import NPMRC, AuthKeyConflictError + +from ...core.credentials.models import CredentialResult + +logger = logging.getLogger(__name__) + + +def _config_path() -> Path: + npm_user_config = os.environ.get("NPM_CONFIG_USERCONFIG") + + if npm_user_config: + return Path(npm_user_config) + + return Path.home() / ".npmrc" + + +class PNPMInstaller: + LAUNCHER_NAME = "pnpm-credential-cloudsmith" + TARGET_CMD = "cloudsmith credential-helper pnpm" + DEFAULT_HOST = "npm.cloudsmith.io" + + name = "pnpm" + summary = "pnpm credential helper for Cloudsmith registries" + + @classmethod + def _resolve_target_cmd(cls) -> str: + """Return the command the launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via + ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed + to be on ``PATH`` under that name, so point the launcher at the + absolute executable instead — mirroring the frozen handling in + :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path + is quoted so a directory containing spaces still execs correctly. + """ + if getattr(sys, "frozen", False): + return f'"{sys.executable}" credential-helper pnpm' + return cls.TARGET_CMD + + def install( + self, + *, + bin_dir: str | None = None, + domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + credential: CredentialResult | None = None, + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the pnpm credential helper. + + Writes the launcher binary and registers Cloudsmith registry hosts in + ``${NPM_CONFIG_USERCONFIG:-~/.npmrc}``. + + Parameters + ---------- + bin_dir: + Override for the directory to install the launcher. Defaults to + :func:`resolve_bin_dir` auto-detection. + domains: + Additional registry hostnames to configure (in addition to the + default ``npm.cloudsmith.io``). + discover: + When ``True`` (default), attempt to auto-discover pnpm custom + domains via the Cloudsmith API. Discovery is best-effort and never + prevents the defaults from being registered. + refresh: + When ``True``, bypass the domain cache and fetch fresh data from + the API. Only meaningful when *discover* is also ``True``. + org: + Cloudsmith organisation slug used for custom-domain discovery. + credential: + Resolved credential used for custom-domain discovery. + api_host: + Cloudsmith API host URL override. + dry_run: + When ``True``, compute and return planned actions without writing + any files. + + Returns + ------- + list[str] + Human-readable descriptions of actions taken (or planned, when + *dry_run* is ``True``). + """ + target_dir = resolve_bin_dir(bin_dir) + config_path = _config_path() + + actions: list[str] = [] + + # Start with the default host plus any explicitly requested domains. + hosts: list[str] = [self.DEFAULT_HOST, *domains] + + if discover: + if dry_run: + actions.append("skipped custom-domain auto-discovery (dry run)") + elif org and credential and credential.api_key: + try: + discovered = get_format_domains( + org, + BackendKind.NPM, + credential=credential, + api_host=api_host, + refresh=refresh, + ) + except Exception as exc: # pylint: disable=broad-except + # Discovery is best-effort: never let it abort the install of + # the defaults. (Network/SDK errors degrade to a warning; + # ApiException is already handled inside.) + actions.append( + f"WARNING: custom-domain auto-discovery failed: {exc}" + ) + discovered = [] + new_hosts = [h for h in discovered if h not in hosts] + hosts.extend(discovered) + actions.append(f"discovered {len(new_hosts)} new pnpm custom domain(s)") + else: + logger.debug( + "skipped auto-discovery" + " (no organization/credentials; pass --no-discover to silence)" + ) + + # De-duplicate while preserving order + seen: set[str] = set() + deduped: list[str] = [] + for h in hosts: + if h not in seen: + seen.add(h) + deduped.append(h) + hosts = deduped + + # Real install + launcher_path = write_launcher( + target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd(), dry_run=dry_run + ) + if dry_run: + actions.append(f"would write launcher {launcher_path}") + else: + actions.append(f"wrote launcher {launcher_path}") + + with NPMRC(config_path, modifiable=not dry_run) as rc: + for host in hosts: + entry = NPMRC.URLEntry.from_values( + host, "tokenHelper", str(launcher_path) + ) + try: + added = rc.add(entry) + except AuthKeyConflictError as e: + if dry_run: + actions.append( + f"WARNING would not set {entry} in {config_path} as {e} already set" + ) + else: + actions.append( + f"WARNING did not set {entry} in {config_path} as {e} already set" + ) + continue + + if dry_run: + if added: + actions.append(f"would set {entry} in {config_path}") + else: + actions.append( + f"{entry} already set in {config_path} (no change)" + ) + + if rc.failures > 0: + raise PartialInstallError(actions) + elif not rc.modified: + actions.append(f"npmrc already up to date ({config_path})") + + return actions + + def uninstall( + self, *, bin_dir: str | None = None, dry_run: bool = False + ) -> list[str]: + config_path = _config_path() + actions: list[str] = [] + + target_dir = resolve_bin_dir(bin_dir) + + if not config_path.exists(): + actions.append(".npmrc file doesn't exist, nothing to do") + return actions + + if os.name == "nt": + launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + with NPMRC(config_path, modifiable=not dry_run) as rc: + hosts: list[str] = rc.helped_hosts(str(launcher_path)) + for host in hosts: + removed = rc.remove(NPMRC.URLEntry.from_values(host, "tokenHelper")) + if dry_run: + if removed: + actions.append( + f"would remove //{host}/:tokenHelper from {config_path}" + ) + else: + actions.append(f"domain {host} not installed in {config_path}") + + removed = remove_launcher(target_dir, self.LAUNCHER_NAME, dry_run=dry_run) + if removed: + if dry_run: + actions.append(f"would remove launcher {launcher_path}") + else: + actions.append(f"removed launcher {launcher_path}") + else: + actions.append(f"launcher not found at {launcher_path} (nothing to remove)") + + return actions + + def status(self) -> dict: + """Return current installation status. + + Returns + ------- + dict + A dict with keys: + + ``"launcher"`` + The :class:`~pathlib.Path` of the launcher if it exists, + else ``None``. + ``"hosts"`` + List of hostnames in ``config.json``'s ``credHelpers`` block + whose value equals ``"cloudsmith"``. + """ + target_dir = resolve_bin_dir() + if os.name == "nt": + launcher_path: Path | None = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + if launcher_path is not None and not launcher_path.exists(): + launcher_path = None + + config_path = _config_path() + + hosts: list[str] = [] + if config_path.exists(): + with NPMRC(config_path) as rc: + hosts: list[str] = rc.helped_hosts(str(launcher_path)) + + return { + "launcher": str(launcher_path) if launcher_path is not None else None, + "hosts": hosts, + } diff --git a/cloudsmith_cli/credential_helpers/pnpm/rc.py b/cloudsmith_cli/credential_helpers/pnpm/rc.py new file mode 100644 index 00000000..39a36996 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/rc.py @@ -0,0 +1,185 @@ +# Copyright 2026 Cloudsmith Ltd +from pathlib import Path + +from typing_extensions import Self + + +class AuthKeyConflictError(KeyError): + """Raised when adding tokenHelper but auth is already set""" + + +class NPMRC: + class URLEntry(str): + _key: str + _value: str | None + + @classmethod + def from_values(cls, domain: str, key: str, value: str | None = None): + obj = cls( + f"//{domain}/:{key}=" if value is None else f"//{domain}/:{key}={value}" + ) + if value is None: + obj._value = None + return obj + + @property + def id(self) -> str: + return f"{self._domain}::{self._key}" + + def __new__(cls, value): + obj = super().__new__(cls, value) + return obj + + def __init__(self, entry: str): + self._raw: str = entry + + ## if line contains trailing comment, ignore + for i, c in enumerate(entry): + if c in ";#": + entry = entry[:i] + break + + # remove any starting whitespace + stripped_entry = entry.lstrip() + + # track the starting whitespace + self._leading = entry[: len(entry) - len(stripped_entry)] + if not stripped_entry.startswith("/"): + raise ValueError("invalid url, should start with ``//``") + + stripped_entry = stripped_entry.lstrip("/") + + domain, kv = stripped_entry.split(":", 1) + self._domain = domain.rstrip("/") + if not self._domain: + raise ValueError(f"entry {self._raw} is missing domain") + + self._key, self._value = kv.split("=", 1) + if not self._key: + raise ValueError(f"entry {self._raw} is missing key") + + def __str__(self) -> str: + return f"{self._leading}//{self._domain}/:{self._key}={self._value}" + + @property + def modified(self): + return self._modified + + @property + def failures(self): + return self._failures + + def __init__(self, path: Path, modifiable=False) -> None: + self._failures = 0 + self._modifiable = modifiable + self._modified = False + self._path: Path = path + self._lines: list[str | NPMRC.URLEntry] = [] + self._mapping: dict[str, str | None] = {} + + def __enter__(self) -> Self: + if not self._path.exists(): + if self._modifiable: + self._path.touch() + else: + return self + + self.parse() + + return self + + def __exit__(self, *_): + self.write() + return False + + def __contains__(self, item: str | URLEntry) -> bool: + if isinstance(item, NPMRC.URLEntry): + return item.id in self._mapping and ( + item._value is None or self._mapping[item.id] == item._value + ) + + return any(item in line for line in self._lines) + + def create_if_not_exists(self): + if not self._path.exists(): + self._path.touch() + + def parse(self): + with open(self._path) as f: + for line in f: + if line.lstrip().startswith("//"): + try: + entry = NPMRC.URLEntry(line.rstrip("\n")) + self._lines.append(entry) + self._mapping[entry.id] = entry._value + except Exception: + self._lines.append(line.rstrip("\n")) + else: + self._lines.append(line.rstrip("\n")) + + def add(self, entry: URLEntry) -> bool: + if entry in self: + return False + + if NPMRC.URLEntry.from_values(entry._domain, entry._key) in self: + for i, line in enumerate(self._lines): + if isinstance(line, NPMRC.URLEntry) and line.id == entry.id: + self._lines[i] = entry + break + + self._modified = True + self._mapping[entry.id] = entry._value + return True + + if NPMRC.URLEntry.from_values(entry._domain, "_authToken") in self: + self._failures += 1 + raise AuthKeyConflictError("_authToken") + + if NPMRC.URLEntry.from_values(entry._domain, "_auth") in self: + self._failures += 1 + raise AuthKeyConflictError("_auth") + + if NPMRC.URLEntry.from_values(entry._domain, "_password") in self: + self._failures += 1 + raise AuthKeyConflictError("_password") + + self._mapping[entry.id] = entry._value + self._lines.append(entry) + self._modified = True + return True + + def remove(self, entry: URLEntry) -> bool: + if entry.id not in self._mapping: + return False + + for i, line in enumerate(self._lines): + if ( + isinstance(line, NPMRC.URLEntry) + and line._domain == entry._domain + and line._key == entry._key + ): + self._lines.pop(i) + del self._mapping[line.id] + self._modified = True + return True + + return False + + def helped_hosts(self, v: str) -> list[str]: + return [ + line._domain + for line in self._lines + if isinstance(line, NPMRC.URLEntry) + and line._key == "tokenHelper" + and line._value == v + ] + + def write(self): + if not self._modifiable: + return + + if not self.modified: + return + + with open(self._path, "w") as f: + f.write("\n".join(self._lines)) diff --git a/cloudsmith_cli/credential_helpers/pnpm/runtime.py b/cloudsmith_cli/credential_helpers/pnpm/runtime.py new file mode 100644 index 00000000..87fde0cc --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/runtime.py @@ -0,0 +1,68 @@ +# Copyright 2026 Cloudsmith Ltd +import logging + +from ..backends import BackendKind +from ..common import is_cloudsmith_domain + +logger = logging.getLogger(__name__) + +_REFUSAL_MESSAGE = ( + "Error: Unable to retrieve credentials. " + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`." +) + + +def get_pnpm_credentials(server_url, credential=None, api_host=None, org=None): + """ + Get credentials for a Cloudsmith pnpm registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The pnpm registry server URL + credential: Pre-resolved CredentialResult from the provider chain + api_host: Cloudsmith API host URL + org: Organisation slug whose custom domains to match against + + Returns: + str: the token in plain text, with no newline at the end + """ + if not credential or not credential.api_key: + return None + + if not is_cloudsmith_domain( + server_url, + credential=credential, + api_host=api_host, + backend_kind=BackendKind.NPM, + org=org, + ): + return None + + return credential.api_key + + +def execute( + repo, credential=None, api_host=None, org=None +) -> tuple[int, str | None, str | None]: + return _get_execute(repo, credential=credential, api_host=api_host, org=org) + + +def _get_execute( + server_url: str, credential=None, api_host=None, org=None +) -> tuple[int, str | None, str | None]: + try: + cred = get_pnpm_credentials( + server_url, credential=credential, api_host=api_host, org=org + ) + if not cred: + return (1, None, _REFUSAL_MESSAGE) + + return (0, cred, None) + except Exception as exc: + logger.debug("pnpm credential-helper get failed: %s", exc, exc_info=True) + + return 1, None, _REFUSAL_MESSAGE diff --git a/cloudsmith_cli/credential_helpers/pnpm/tests/__init__.py b/cloudsmith_cli/credential_helpers/pnpm/tests/__init__.py new file mode 100644 index 00000000..c4541398 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/tests/__init__.py @@ -0,0 +1 @@ +# Copyright 2026 Cloudsmith Ltd diff --git a/cloudsmith_cli/credential_helpers/pnpm/tests/test_rc.py b/cloudsmith_cli/credential_helpers/pnpm/tests/test_rc.py new file mode 100644 index 00000000..53466bca --- /dev/null +++ b/cloudsmith_cli/credential_helpers/pnpm/tests/test_rc.py @@ -0,0 +1,1089 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for NPMRC (NPM configuration file) management.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from ..rc import NPMRC, AuthKeyConflictError + + +class TestURLEntry: + """Tests for NPMRC.URLEntry class.""" + + def test_urlentry_from_values_with_value(self): + """Create a URLEntry with a value from factory method.""" + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert str(entry) == "//registry.example.com/:tokenHelper=my-helper" + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "my-helper" + + def test_urlentry_from_values_without_value(self): + """Create a URLEntry without a value from factory method.""" + entry = NPMRC.URLEntry.from_values("registry.example.com", "tokenHelper", None) + # The from_values method sets _value to None, but __new__ creates the string + # with an empty value, which then gets parsed back during __init__ + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value is None + + def test_urlentry_id_property(self): + """The id property combines domain and key.""" + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper" + ) + assert entry.id == "registry.example.com::tokenHelper" + + def test_urlentry_parse_basic(self): + """Parse a basic .npmrc line.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=my-helper") + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "my-helper" + + def test_urlentry_parse_with_leading_whitespace(self): + """Parse entry with leading whitespace.""" + entry = NPMRC.URLEntry(" //registry.example.com/:tokenHelper=value") + assert entry._leading == " " + assert entry._domain == "registry.example.com" + assert entry._key == "tokenHelper" + assert entry._value == "value" + assert str(entry) == " //registry.example.com/:tokenHelper=value" + + def test_urlentry_parse_with_comment_semicolon(self): + """Parse entry with trailing semicolon comment.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=value;comment here") + assert entry._value == "value" + + def test_urlentry_parse_with_comment_hash(self): + """Parse entry with trailing hash comment.""" + entry = NPMRC.URLEntry("//registry.example.com/:tokenHelper=value#comment here") + assert entry._value == "value" + + def test_urlentry_parse_invalid_no_slashes(self): + """Invalid entry must start with //.""" + with pytest.raises(ValueError, match="should start with"): + NPMRC.URLEntry("registry.example.com/:tokenHelper=value") + + def test_urlentry_parse_invalid_missing_domain(self): + """Invalid entry without domain.""" + with pytest.raises(ValueError, match="missing domain"): + NPMRC.URLEntry("//:tokenHelper=value") + + def test_urlentry_parse_invalid_missing_key(self): + """Invalid entry without key.""" + with pytest.raises(ValueError, match="missing key"): + NPMRC.URLEntry("//registry.example.com/:=value") + + def test_urlentry_domain_with_trailing_slash(self): + """Parse and normalize domain with trailing slash.""" + entry = NPMRC.URLEntry("//registry.example.com//:tokenHelper=value") + assert entry._domain == "registry.example.com" + + def test_urlentry_contains_logic(self): + """Test URLEntry containment for different values.""" + entry1 = NPMRC.URLEntry("//registry.example.com/:tokenHelper=helper1") + entry2 = NPMRC.URLEntry("//registry.example.com/:tokenHelper=helper2") + entry_none = NPMRC.URLEntry("//registry.example.com/:tokenHelper=") + + # Same entry is contained + assert entry1 in [entry1] + + # Different value is not contained + assert entry1 not in [entry2] + + # None value in entry matches any token + assert entry_none not in [entry1] # entry_none has no value + + +class TestNPMRC: + """Tests for NPMRC class.""" + + def test_npmrc_create_new_file(self): + """Create a new NPMRC instance with non-existent file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + npmrc = NPMRC(rc_path, modifiable=True) + assert npmrc.modified is False + assert npmrc.failures == 0 + + def test_npmrc_context_manager_creates_file(self): + """Using NPMRC as context manager creates file if modifiable.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True): + assert rc_path.exists() + + def test_npmrc_context_manager_nonexistent_nonmodifiable(self): + """Non-modifiable NPMRC doesn't create non-existent file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=False): + assert not rc_path.exists() + + def test_npmrc_parse_simple_file(self): + """Parse a simple .npmrc file with multiple entries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "//npm.example.com/:tokenHelper=custom\n" + "some_other_setting=value\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 3 + assert npmrc._mapping["registry.npmjs.org::tokenHelper"] == "npm" + assert npmrc._mapping["npm.example.com::tokenHelper"] == "custom" + + def test_npmrc_parse_preserves_non_url_entries(self): + """Parsing preserves non-URL entries as strings.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "registry=https://registry.npmjs.org/\n" + "//registry.npmjs.org/:tokenHelper=npm\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + assert npmrc._lines[0] == "registry=https://registry.npmjs.org/" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + + def test_npmrc_parse_ignores_invalid_lines(self): + """Parsing gracefully skips invalid URL lines. + + Invalid lines that start with // but don't parse correctly are stored + as raw strings and not added to the mapping. This prevents crashes + on malformed entries while preserving the original content. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "// malformed comment line\n" + "//missing-domain/\n" + "//example.com/:validKey=validValue\n" + "//domain.com/noKeyValuePair\n" + ) + # Should parse without raising an exception + with NPMRC(rc_path, modifiable=False) as npmrc: + # Should have 5 lines total + assert len(npmrc._lines) == 5 + + # Valid entries should be URLEntry objects + assert isinstance(npmrc._lines[0], NPMRC.URLEntry) + assert isinstance(npmrc._lines[3], NPMRC.URLEntry) + + # Invalid entries should be stored as strings + assert isinstance(npmrc._lines[1], str) + assert isinstance(npmrc._lines[2], str) + assert isinstance(npmrc._lines[4], str) + assert npmrc._lines[1] == "// malformed comment line" + assert npmrc._lines[2] == "//missing-domain/" + assert npmrc._lines[4] == "//domain.com/noKeyValuePair" + + # Only valid entries should be in mapping + assert len(npmrc._mapping) == 2 + assert "registry.npmjs.org::tokenHelper" in npmrc._mapping + assert "example.com::validKey" in npmrc._mapping + + def test_npmrc_parse_user_comment_line(self): + """Parsing handles user-entered comment-like lines gracefully. + + This is the specific case from the PR comment where a user mistakenly + thought // was for comments (like in other formats) and created a + malformed entry "// oh I thought // was for code comments". + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// oh I thought // was for code comments\n" + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-cred\n" + ) + # Should not raise ValueError during parsing + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 2 + + # First line (malformed) should be stored as string + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// oh I thought // was for code comments" + + # Second line (valid) should be parsed + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert npmrc._lines[1]._domain == "npm.cloudsmith.io" + assert npmrc._lines[1]._key == "tokenHelper" + + # Only the valid entry should be in mapping + assert len(npmrc._mapping) == 1 + assert "npm.cloudsmith.io::tokenHelper" in npmrc._mapping + + def test_npmrc_add_entry(self): + """Add a new entry to NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.add(entry) + assert result is True + assert npmrc.modified is True + assert entry.id in npmrc._mapping + + def test_npmrc_add_duplicate_entry_fails(self): + """Adding a duplicate entry returns False.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert npmrc.add(entry) is True + assert npmrc.add(entry) is False + assert npmrc.failures == 0 + + def test_npmrc_add_conflict_with_authToken(self): + """Adding tokenHelper when _authToken exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_authToken=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_authToken"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_conflict_with_auth(self): + """Adding tokenHelper when _auth exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_auth=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_auth"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_conflict_with_password(self): + """Adding tokenHelper when _password exists raises conflict error.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:_password=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + with pytest.raises(AuthKeyConflictError, match="_password"): + npmrc.add(entry) + assert npmrc.failures == 1 + + def test_npmrc_add_no_conflict_different_domain(self): + """Adding tokenHelper to one domain doesn't conflict with _authToken on another.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//other.example.com/:_authToken=secret\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert npmrc.add(entry) is True + assert npmrc.failures == 0 + + def test_npmrc_add_entry_preserves_invalid_lines(self): + """Adding an entry preserves invalid lines in the file. + + When adding a new entry to a file with invalid lines, those invalid + lines should be preserved in their original position and written back + to the file unchanged. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// invalid comment line\n" + "//registry.npmjs.org/:tokenHelper=existing\n" + "//malformed/missing/content\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add a new valid entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "new-helper" + ) + assert npmrc.add(new_entry) is True + + # Verify the lines are in order: invalid, valid, invalid, new valid + assert len(npmrc._lines) == 4 + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// invalid comment line" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert isinstance(npmrc._lines[2], str) + assert npmrc._lines[2] == "//malformed/missing/content" + assert isinstance(npmrc._lines[3], NPMRC.URLEntry) + + # Verify the file content preserves all lines including invalid ones + content = rc_path.read_text() + assert "// invalid comment line" in content + assert "//registry.npmjs.org/:tokenHelper=existing" in content + assert "//malformed/missing/content" in content + assert "//registry.example.com/:tokenHelper=new-helper" in content + + def test_npmrc_remove_entry_preserves_invalid_lines(self): + """Removing an entry preserves invalid lines in the file. + + When removing a valid entry from a file with invalid lines, those + invalid lines should remain in place and be written back unchanged. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// this is not a valid entry\n" + "//registry.example.com/:tokenHelper=helper1\n" + "//registry.other.com/:tokenHelper=helper2\n" + "//another invalid line without proper format\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + # Remove the first valid entry + entry_to_remove = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper1" + ) + assert npmrc.remove(entry_to_remove) is True + + # Verify structure: invalid, removed, valid, invalid + assert len(npmrc._lines) == 3 # removed one valid entry + assert isinstance(npmrc._lines[0], str) + assert npmrc._lines[0] == "// this is not a valid entry" + assert isinstance(npmrc._lines[1], NPMRC.URLEntry) + assert npmrc._lines[1]._domain == "registry.other.com" + assert isinstance(npmrc._lines[2], str) + assert npmrc._lines[2] == "//another invalid line without proper format" + + # Verify the file content preserves invalid lines + content = rc_path.read_text() + assert "// this is not a valid entry" in content + assert ( + "//registry.example.com/:tokenHelper=helper1" not in content + ) # removed + assert "//registry.other.com/:tokenHelper=helper2" in content + assert "//another invalid line without proper format" in content + + def test_npmrc_add_and_remove_preserves_invalid_lines_roundtrip(self): + """Adding and removing entries in sequence preserves invalid lines. + + This test validates that the file can be modified multiple times + while preserving invalid entries throughout the process. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "// user comment\n//registry.a.com/:tokenHelper=a\n//malformed\n" + ) + + # First pass: add and remove + with NPMRC(rc_path, modifiable=True) as npmrc: + new_entry = NPMRC.URLEntry.from_values( + "registry.b.com", "tokenHelper", "b" + ) + npmrc.add(new_entry) + + # Verify file has invalid lines preserved + content = rc_path.read_text() + assert "// user comment" in content + assert "//malformed" in content + + # Second pass: read again and verify structure + with NPMRC(rc_path, modifiable=False) as npmrc: + assert len(npmrc._lines) == 4 # 2 invalid + 2 valid + invalid_count = sum( + 1 for line in npmrc._lines if not isinstance(line, NPMRC.URLEntry) + ) + valid_count = sum( + 1 for line in npmrc._lines if isinstance(line, NPMRC.URLEntry) + ) + assert invalid_count == 2 + assert valid_count == 2 + + def test_npmrc_add_update_existing_entry_modifies_lines(self): + """Updating an existing entry modifies the _lines list in place. + + When adding an entry with the same domain and key but different value, + the entry in _lines should be replaced with the new entry object. + This ensures that __str__() returns the updated value. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert len(npmrc._lines) == 1 + old_entry = npmrc._lines[0] + assert isinstance(old_entry, NPMRC.URLEntry) + assert old_entry._value == "/old/path/helper" + + # Add a new entry with same domain/key but different value + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + result = npmrc.add(new_entry) + + # Verify operation returned True + assert result is True + + # Verify _lines was updated with the new entry object + assert len(npmrc._lines) == 1 + updated_entry = npmrc._lines[0] + assert isinstance(updated_entry, NPMRC.URLEntry) + assert updated_entry is new_entry, ( + "_lines should contain the new entry object, not the old one" + ) + assert updated_entry._value == "/new/path/helper" + + def test_npmrc_add_update_sets_modified_flag(self): + """Updating an existing entry sets the _modified flag to True.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert npmrc.modified is False + + # Update the entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + npmrc.add(new_entry) + + # Verify _modified flag was set + assert npmrc.modified is True + + def test_npmrc_add_update_syncs_mapping(self): + """Updating an entry keeps _mapping in sync with _lines. + + When an entry is updated, the mapping value should reflect the new value. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=/old/path/helper\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial mapping + entry_id = "registry.example.com::tokenHelper" + assert npmrc._mapping[entry_id] == "/old/path/helper" + + # Update the entry + new_entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "/new/path/helper" + ) + npmrc.add(new_entry) + + # Verify mapping was updated + assert npmrc._mapping[entry_id] == "/new/path/helper" + + def test_npmrc_add_update_multiple_entries(self): + """Updating one entry doesn't affect others in _lines. + + Verifies that when multiple tokenHelper entries exist and one is updated, + only the matching entry is replaced while others are preserved. + """ + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.a.com/:tokenHelper=/old/path/helper\n" + "//registry.b.com/:tokenHelper=/other/path/helper\n" + ) + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Verify initial state + assert len(npmrc._lines) == 2 + entry_a = npmrc._lines[0] + assert isinstance(entry_a, NPMRC.URLEntry) + entry_b = npmrc._lines[1] + assert isinstance(entry_b, NPMRC.URLEntry) + assert entry_a._domain == "registry.a.com" + assert entry_b._domain == "registry.b.com" + + # Update only registry.a.com + new_entry_a = NPMRC.URLEntry.from_values( + "registry.a.com", "tokenHelper", "/new/path/helper" + ) + result = npmrc.add(new_entry_a) + + # Verify update was successful + assert result is True + + # Verify _lines structure: updated a, unchanged b + assert len(npmrc._lines) == 2 + assert npmrc._lines[0] is new_entry_a + assert npmrc._lines[0]._value == "/new/path/helper" + assert npmrc._lines[1] is entry_b # Unchanged + assert npmrc._lines[1]._value == "/other/path/helper" + + def test_npmrc_remove_entry(self): + """Remove an entry from NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=my-helper\n") + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.remove(entry) + assert result is True + assert npmrc.modified is True + assert entry.id not in npmrc._mapping + + def test_npmrc_remove_nonexistent_entry(self): + """Removing non-existent entry returns False.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + result = npmrc.remove(entry) + assert result is False + assert npmrc.modified is False + + def test_npmrc_contains_urlentry(self): + """Check if URLEntry is contained in NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=my-helper\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + assert entry in npmrc + + def test_npmrc_contains_string(self): + """Check if string is contained in NPMRC.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + assert "registry=https://registry.npmjs.org/" in npmrc + + def test_npmrc_write_modified_file(self): + """Write modified NPMRC back to file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "my-helper" + ) + npmrc.add(entry) + + # Verify written content + content = rc_path.read_text() + assert "//registry.example.com/:tokenHelper=my-helper" in content + + def test_npmrc_write_not_modified_no_write(self): + """Unmodified NPMRC is not written to file.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=existing\n") + original_mtime = rc_path.stat().st_mtime + + # Read without modifying + with NPMRC(rc_path, modifiable=True) as npmrc: + pass + + # File should not be modified + assert rc_path.stat().st_mtime == original_mtime + assert not npmrc.modified + + def test_npmrc_write_nonmodifiable_no_write(self): + """Non-modifiable NPMRC does not write.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "registry.example.com", "tokenHelper", "helper" + ) + npmrc.add(entry) + + # Clear file + rc_path.unlink() + + # Try to modify with non-modifiable instance + with NPMRC(rc_path, modifiable=False) as npmrc: + npmrc._modified = True # Force modified flag + + # File should not exist + assert not rc_path.exists() + + def test_npmrc_helped_hosts_single(self): + """Get list of hosts with tokenHelper.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry.npmjs.org/:tokenHelper=npm\n" + "//npm.example.com/:tokenHelper=custom\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("npm") + assert hosts == ["registry.npmjs.org"] + + def test_npmrc_helped_hosts_multiple(self): + """Get list of hosts with same tokenHelper value.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//registry1.example.com/:tokenHelper=my-helper\n" + "//registry2.example.com/:tokenHelper=my-helper\n" + "//registry3.example.com/:tokenHelper=other\n" + ) + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("my-helper") + assert sorted(hosts) == [ + "registry1.example.com", + "registry2.example.com", + ] + + def test_npmrc_helped_hosts_empty(self): + """Get empty list when no hosts use tokenHelper.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("//registry.example.com/:tokenHelper=other\n") + with NPMRC(rc_path, modifiable=False) as npmrc: + hosts = npmrc.helped_hosts("nonexistent") + assert hosts == [] + + def test_npmrc_roundtrip_preserves_format(self): + """Writing and reading preserves file format.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_content = ( + "registry=https://registry.npmjs.org/\n" + "//registry.example.com/:tokenHelper=my-helper\n" + " //custom.example.com/:_authToken=secret\n" + ) + rc_path.write_text(original_content) + + # Read, add entry, write + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "new.example.com", "tokenHelper", "new" + ) + npmrc.add(entry) + + # Read again + content = rc_path.read_text() + assert "registry=https://registry.npmjs.org/" in content + assert "//custom.example.com/:_authToken=secret" in content + assert "//new.example.com/:tokenHelper=new" in content + + def test_npmrc_create_if_not_exists(self): + """Explicitly create NPMRC file if it doesn't exist.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + npmrc = NPMRC(rc_path, modifiable=True) + assert not rc_path.exists() + npmrc.create_if_not_exists() + assert rc_path.exists() + + def test_npmrc_create_if_not_exists_already_exists(self): + """Create if not exists doesn't fail if file already exists.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("existing content\n") + npmrc = NPMRC(rc_path, modifiable=True) + npmrc.create_if_not_exists() + assert rc_path.read_text() == "existing content\n" + + def test_npmrc_multiple_operations(self): + """Test multiple add/remove operations in sequence.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add entries + e1 = NPMRC.URLEntry.from_values("reg1.example.com", "tokenHelper", "h1") + e2 = NPMRC.URLEntry.from_values("reg2.example.com", "tokenHelper", "h2") + e3 = NPMRC.URLEntry.from_values("reg3.example.com", "tokenHelper", "h3") + + assert npmrc.add(e1) is True + assert npmrc.add(e2) is True + assert npmrc.add(e3) is True + assert npmrc.modified is True + + # Remove middle entry + assert npmrc.remove(e2) is True + + # Verify state + assert e1.id in npmrc._mapping + assert e2.id not in npmrc._mapping + assert e3.id in npmrc._mapping + + def test_npmrc_failure_counter(self): + """Failure counter increments on conflicts.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "//reg1.example.com/:_authToken=secret\n" + "//reg2.example.com/:_password=pwd\n" + ) + with NPMRC(rc_path, modifiable=True) as npmrc: + assert npmrc.failures == 0 + + e1 = NPMRC.URLEntry.from_values("reg1.example.com", "tokenHelper", "h1") + try: + npmrc.add(e1) + except AuthKeyConflictError: + pass + assert npmrc.failures == 1 + + e2 = NPMRC.URLEntry.from_values("reg2.example.com", "tokenHelper", "h2") + try: + npmrc.add(e2) + except AuthKeyConflictError: + pass + assert npmrc.failures == 2 + + +class TestAuthKeyConflictError: + """Tests for AuthKeyConflictError exception.""" + + def test_authkeyconflict_is_keyerror(self): + """AuthKeyConflictError is a subclass of KeyError.""" + exc = AuthKeyConflictError("_authToken") + assert isinstance(exc, KeyError) + + def test_authkeyconflict_message(self): + """AuthKeyConflictError preserves message.""" + exc = AuthKeyConflictError("_auth") + assert exc.args[0] == "_auth" + + +class TestInstallingAndUninstallingWithRealWorldConfigs: + """Integration tests with realistic pre-configured .npmrc files.""" + + def test_install_appends_line_to_minimal_npmrc(self): + """Installing appends tokenHelper line to minimal .npmrc.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(entry) is True + + # Verify file content - check exact lines + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] # exclude empty + assert len(lines) == 2 + assert lines[0] == "registry=https://registry.npmjs.org/" + assert ( + lines[1] + == "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + ) + + def test_install_preserves_all_other_lines(self): + """Installing preserves all non-related lines in .npmrc.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "legacy-peer-deps=true", + "@myorg:registry=https://custom.example.com/", + "//custom.example.com/:always-auth=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(entry) is True + + # Verify all original lines are still there (exact match) + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + for original_line in original_lines: + assert original_line in lines + assert ( + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + in lines + ) + + def test_uninstall_removes_only_target_line(self): + """Uninstalling removes only the target tokenHelper line.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith", + "legacy-peer-deps=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.remove(entry) is True + + # Verify only the target line was removed + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + assert "registry=https://registry.npmjs.org/" in lines + assert "legacy-peer-deps=true" in lines + assert not any("//npm.cloudsmith.io/:tokenHelper" in line for line in lines) + + def test_install_multiple_registries_preserves_order(self): + """Installing multiple registries preserves existing order.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//registry1.example.com/:always-auth=true", + "//registry2.example.com/:always-auth=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry1 = NPMRC.URLEntry.from_values( + "cloudsmith1.io", "tokenHelper", "/path/to/helper1" + ) + entry2 = NPMRC.URLEntry.from_values( + "cloudsmith2.io", "tokenHelper", "/path/to/helper2" + ) + assert npmrc.add(entry1) is True + assert npmrc.add(entry2) is True + + # Verify original lines are in original order, new lines at end + lines = [line for line in rc_path.read_text().split("\n") if line] + assert lines[0] == "registry=https://registry.npmjs.org/" + assert lines[1] == "//registry1.example.com/:always-auth=true" + assert lines[2] == "//registry2.example.com/:always-auth=true" + assert lines[3] == "//cloudsmith1.io/:tokenHelper=/path/to/helper1" + assert lines[4] == "//cloudsmith2.io/:tokenHelper=/path/to/helper2" + + def test_install_then_uninstall_leaves_clean_state(self): + """Installing then uninstalling returns file to original state.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken=secret", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + # Install + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content_after_install = rc_path.read_text() + lines_after_install = [ + line for line in content_after_install.split("\n") if line + ] + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines_after_install + + # Uninstall + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.remove(entry) is True + + content_after_uninstall = rc_path.read_text() + lines_after_uninstall = [ + line for line in content_after_uninstall.split("\n") if line + ] + # Verify all original lines are still present (exact match) + for original_line in original_lines: + assert original_line in lines_after_uninstall + # Verify cloudsmith entry was removed + assert not any( + "//cloudsmith.io/:tokenHelper" in line for line in lines_after_uninstall + ) + + def test_install_with_scoped_packages(self): + """Installing works alongside scoped package registries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "@myorg:registry=https://custom.example.com/", + "@another:registry=https://another.example.com/", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # All scoped registries should still be there + assert "@myorg:registry=https://custom.example.com/" in lines + assert "@another:registry=https://another.example.com/" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines + + def test_install_with_commented_lines(self): + """Installing preserves commented-out lines.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "# //old.registry.com/:_authToken=disabled", + "legacy-peer-deps=true", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # Commented line should be preserved (exact match) + assert "# //old.registry.com/:_authToken=disabled" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines + + def test_uninstall_idempotent(self): + """Uninstalling same entry twice is safe (idempotent).""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "//cloudsmith.io/:tokenHelper=/path/helper", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + # First removal succeeds + assert npmrc.remove(entry) is True + # Second removal returns False (already removed) + assert npmrc.remove(entry) is False + + def test_install_with_complex_registry_config(self): + """Installing to complex real-world registry configuration.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + "npm_config_loglevel=warn", + "@babel:registry=https://registry.npmjs.org/", + "@types:registry=https://registry.npmjs.org/", + "//registry.npmjs.org/:_authToken=npm_secret_token_here", + "always-auth=false", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + cloudsmith_entry = NPMRC.URLEntry.from_values( + "npm.cloudsmith.io", + "tokenHelper", + "/usr/local/bin/npm-credentials-cloudsmith", + ) + assert npmrc.add(cloudsmith_entry) is True + + # Verify after context manager exit + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # Original auth still there + assert "//registry.npmjs.org/:_authToken=npm_secret_token_here" in lines + # New entry added + assert ( + "//npm.cloudsmith.io/:tokenHelper=/usr/local/bin/npm-credentials-cloudsmith" + in lines + ) + # All original lines preserved + for original_line in original_lines: + assert original_line in lines + + def test_install_different_helpers_same_domain(self): + """Installing different credential helpers to same domain.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text("registry=https://registry.npmjs.org/\n") + + with NPMRC(rc_path, modifiable=True) as npmrc: + # Add tokenHelper for cloudsmith + entry1 = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/to/helper1" + ) + assert npmrc.add(entry1) is True + + # Add custom setting for same domain + entry2 = NPMRC.URLEntry.from_values( + "cloudsmith.io", + "registryUrl", + "https://cloudsmith.io/npm/myorg/myrepo/", + ) + assert npmrc.add(entry2) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + assert "//cloudsmith.io/:tokenHelper=/path/to/helper1" in lines + assert ( + "//cloudsmith.io/:registryUrl=https://cloudsmith.io/npm/myorg/myrepo/" + in lines + ) + + def test_roundtrip_with_leading_whitespace(self): + """Roundtrip preserves leading whitespace on entries.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + original_lines = [ + "registry=https://registry.npmjs.org/", + " //indented.example.com/:_authToken=secret", + ] + rc_path.write_text("\n".join(original_lines) + "\n") + + # Read and re-write without changes + with NPMRC(rc_path, modifiable=True): + # Don't modify, just read + pass + + content = rc_path.read_text() + lines = content.split("\n") + # Indentation should be preserved (exact match) + assert " //indented.example.com/:_authToken=secret" in lines + + def test_install_with_empty_lines(self): + """Installing handles .npmrc with empty lines gracefully.""" + with TemporaryDirectory() as tmpdir: + rc_path = Path(tmpdir) / ".npmrc" + rc_path.write_text( + "registry=https://registry.npmjs.org/\n\nlegacy-peer-deps=true\n\n" + ) + + with NPMRC(rc_path, modifiable=True) as npmrc: + entry = NPMRC.URLEntry.from_values( + "cloudsmith.io", "tokenHelper", "/path/helper" + ) + assert npmrc.add(entry) is True + + content = rc_path.read_text() + lines = [line for line in content.split("\n") if line] + # All non-empty content preserved + assert "registry=https://registry.npmjs.org/" in lines + assert "legacy-peer-deps=true" in lines + assert "//cloudsmith.io/:tokenHelper=/path/helper" in lines diff --git a/pyproject.toml b/pyproject.toml index c040aa4f..95548fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ include-package-data = false [tool.setuptools.packages.find] include = ["cloudsmith_cli*"] -exclude = ["cloudsmith_cli.tests*", "cloudsmith_cli.*.tests*"] +exclude = ["cloudsmith_cli.tests*", "cloudsmith_cli.*.tests*", "cloudsmith_cli.*.*.tests*"] namespaces = false [tool.setuptools.package-data]