Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/next-release/enhancement-CodeArtifact-66239.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "enhancement",
"category": "CodeArtifact",
"description": "Updated npm login to write configuration directly to .npmrc, consistent with how other package manager integrations handle their config files."
}
78 changes: 72 additions & 6 deletions awscli/customizations/codeartifact/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import platform
import re
import secrets
import subprocess
import sys
from configparser import RawConfigParser
Expand Down Expand Up @@ -460,6 +461,14 @@ def login(self, dry_run=False):
commands = self.get_commands(
self.repository_endpoint, self.auth_token, scope=scope
)

if not dry_run:
repo_uri = urlsplit(self.repository_endpoint)
auth_token_key = f'//{repo_uri.netloc}{repo_uri.path}:_authToken'
self._write_npmrc_value(
auth_token_key, self.auth_token, self.get_npmrc_path()
)

self._run_commands('npm', commands, dry_run)

def _run_command(self, tool, command):
Expand Down Expand Up @@ -488,6 +497,69 @@ def get_scope(cls, namespace):

return scope

@classmethod
def get_npmrc_path(cls):
custom = os.environ.get('NPM_CONFIG_USERCONFIG')
if custom:
return os.path.expanduser(custom)
return os.path.join(os.path.expanduser('~'), '.npmrc')

def _write_npmrc_value(self, key, value, npmrc_path):
new_entry = f'{key}={value}'
pattern = re.compile(
r'^' + re.escape(key) + r'=.*$', re.M
)
if not os.path.isfile(npmrc_path):
self._create_npmrc_file(npmrc_path, new_entry)
else:
with open(npmrc_path) as f:
contents = f.read()

if pattern.search(contents):
new_contents = pattern.sub(lambda _: new_entry, contents)
else:
new_contents = self._append_npmrc_entry(
contents, new_entry
)

dirname = os.path.dirname(npmrc_path) or '.'
fd, tmp_path = self._create_tmp_file(dirname)

try:
with os.fdopen(fd, 'w') as f:
f.write(new_contents)
os.replace(tmp_path, npmrc_path)
except BaseException:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise

def _create_tmp_file(self, dirname):
for _ in range(10):
suffix = secrets.token_hex(8)
tmp_path = os.path.join(dirname, f'.npmrc.tmp.{suffix}')
try:
fd = os.open(tmp_path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
return fd, tmp_path
except FileExistsError:
continue
raise RuntimeError('Unable to create temporary file for .npmrc')

def _create_npmrc_file(self, npmrc_path, new_entry):
dirname = os.path.split(npmrc_path)[0] or '.'
os.makedirs(dirname, exist_ok=True)
with os.fdopen(
os.open(npmrc_path, os.O_WRONLY | os.O_CREAT, 0o600), 'w'
) as f:
f.write(new_entry + '\n')

def _append_npmrc_entry(self, contents, new_entry):
if contents.endswith('\n'):
return contents + new_entry + '\n'
else:
return contents + '\n' + new_entry + '\n'

@classmethod
def get_commands(cls, endpoint, auth_token, **kwargs):
commands = []
Expand All @@ -507,12 +579,6 @@ def get_commands(cls, endpoint, auth_token, **kwargs):
[cls.NPM_CMD, 'config', 'set', always_auth_config, 'true']
)

# set auth info for the repository.
auth_token_config = f'//{repo_uri.netloc}{repo_uri.path}:_authToken'
commands.append(
[cls.NPM_CMD, 'config', 'set', auth_token_config, auth_token]
)

return commands


Expand Down
14 changes: 10 additions & 4 deletions tests/functional/codeartifact/test_codeartifact_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,22 @@ def setUp(self):
self.subprocess_check_out_mock = (
self.subprocess_check_output_patch.start()
)

self.test_npmrc_path = self.file_creator.full_path('.npmrc')
self.get_npmrc_path_patch = mock.patch(
'awscli.customizations.codeartifact.login.NpmLogin'
'.get_npmrc_path'
)
self.get_npmrc_path_mock = self.get_npmrc_path_patch.start()
self.get_npmrc_path_mock.return_value = self.test_npmrc_path

self.cli_runner = CLIRunner()

def tearDown(self):
self.pypi_rc_path_patch.stop()
self.subprocess_check_output_patch.stop()
self.get_netrc_path_patch.stop()
self.get_npmrc_path_patch.stop()
self.subprocess_patch.stop()
self.file_creator.remove_all()

Expand Down Expand Up @@ -208,17 +218,13 @@ def _get_npm_commands(self, **kwargs):

repo_uri = urlsplit(self.endpoint)
always_auth_config = f'//{repo_uri.netloc}{repo_uri.path}:always-auth'
auth_token_config = f'//{repo_uri.netloc}{repo_uri.path}:_authToken'

scope = kwargs.get('scope')
registry = f'{scope}:registry' if scope else 'registry'

commands = []
commands.append([npm_cmd, 'config', 'set', registry, self.endpoint])
commands.append([npm_cmd, 'config', 'set', always_auth_config, 'true'])
commands.append(
[npm_cmd, 'config', 'set', auth_token_config, self.auth_token]
)

return commands

Expand Down
Loading
Loading