Repository: aws/aws-cli

Issue:
Add scope option for npm login on CodeArtifact 
**Is your feature request related to a problem? Please describe.**
When using the `aws codeartifact login --tool npm` command, the configuration is set on the user's global npm profile. If a developer has multiple repositories across different organizations, they will be unable to run an `npm install` on another private npm repo due to the CodeArtifact configuration being set on the user level without scoping applied.

**Describe the solution you'd like**
Add an option for specifying scope when running the `aws codeartifact login --tool npm` command. See: https://docs.npmjs.com/using-npm/scope.html

**Describe alternatives you've considered**
I've considered assigning the output of `aws codeartifact get-authorization-token` to an environment variable, with the corresponding variable referenced in `.npmrc` however, this is harder to automate for developer machines across different operating systems.

**Additional context**
Running
`aws codeartifact login --tool npm --repository my-repo --domain my-domain`

results in
```sh
; userconfig ~/.npmrc
//my-domain-1234567890.d.codeartifact.us-east-1.amazonaws.com/npm/my-repo/:always-auth = true
registry = "https://my-domain-1234567890.d.codeartifact.us-east-1.amazonaws.com/npm/my-repo/"
```

The feature could look something like this
`aws codeartifact login --tool npm --repository my-repo --domain my-domain --scope "@myscope"`

resulting in:

```sh
; userconfig ~/.npmrc
//my-domain-1234567890.d.codeartifact.us-east-1.amazonaws.com/npm/my-repo/:always-auth = true
@myscope-scope:registry = "https://my-domain-1234567890.d.codeartifact.us-east-1.amazonaws.com/npm/my-repo/"
```



Discussion:
Looking into this, @ryansonshine!
Hi @ryansonshine, the CodeArtifact team has been notified about this feature request, but I don't have a date where this will be addressed. Thanks again!
Thanks @kdaily , if I open a PR for this change will it be reviewed?
Hi @ryansonshine, the CodeArtifact team is open to a PR - they are going to review the feature and can provide an update tentatively next week (~07/14/2020). I'll check back in then, thanks!
Thanks! I'll start working on it this week and will submit a PR.

Resolving patch:
diff --git a/awscli/customizations/codeartifact/login.py b/awscli/customizations/codeartifact/login.py
--- a/awscli/customizations/codeartifact/login.py
+++ b/awscli/customizations/codeartifact/login.py
@@ -3,6 +3,7 @@
 import platform
 import sys
 import subprocess
+import re
 
 from datetime import datetime
 from dateutil.tz import tzutc
@@ -34,12 +35,13 @@ def get_relative_expiration_time(remaining):
 
 class BaseLogin(object):
 
-    def __init__(self, auth_token,
-                 expiration, repository_endpoint, subprocess_utils):
+    def __init__(self, auth_token, expiration,
+                 repository_endpoint, subprocess_utils, namespace=None):
         self.auth_token = auth_token
         self.expiration = expiration
         self.repository_endpoint = repository_endpoint
         self.subprocess_utils = subprocess_utils
+        self.namespace = namespace
 
     def login(self, dry_run=False):
         raise NotImplementedError('login()')
@@ -99,18 +101,47 @@ class NpmLogin(BaseLogin):
     NPM_CMD = 'npm.cmd' if platform.system().lower() == 'windows' else 'npm'
 
     def login(self, dry_run=False):
+        scope = self.get_scope(
+            self.namespace
+        )
         commands = self.get_commands(
-            self.repository_endpoint, self.auth_token
+            self.repository_endpoint, self.auth_token, scope=scope
         )
         self._run_commands('npm', commands, dry_run)
 
+    @classmethod
+    def get_scope(cls, namespace):
+        # Regex for valid scope name
+        valid_scope_name = re.compile('^(@[a-z0-9-~][a-z0-9-._~]*)')
+
+        if namespace is None:
+            return namespace
+
+        # Add @ prefix to scope if it doesn't exist
+        if namespace.startswith('@'):
+            scope = namespace
+        else:
+            scope = '@{}'.format(namespace)
+
+        if not valid_scope_name.match(scope):
+            raise ValueError(
+                'Invalid scope name, scope must contain URL-safe '
+                'characters, no leading dots or underscores'
+            )
+
+        return scope
+
     @classmethod
     def get_commands(cls, endpoint, auth_token, **kwargs):
         commands = []
+        scope = kwargs.get('scope')
+
+        # prepend scope if it exists
+        registry = '{}:registry'.format(scope) if scope else 'registry'
 
         # set up the codeartifact repository as the npm registry.
         commands.append(
-            [cls.NPM_CMD, 'config', 'set', 'registry', endpoint]
+            [cls.NPM_CMD, 'config', 'set', registry, endpoint]
         )
 
         repo_uri = urlparse.urlsplit(endpoint)
@@ -276,15 +307,18 @@ class CodeArtifactLogin(BasicCommand):
     TOOL_MAP = {
         'npm': {
             'package_format': 'npm',
-            'login_cls': NpmLogin
+            'login_cls': NpmLogin,
+            'namespace_support': True,
         },
         'pip': {
             'package_format': 'pypi',
-            'login_cls': PipLogin
+            'login_cls': PipLogin,
+            'namespace_support': False,
         },
         'twine': {
             'package_format': 'pypi',
-            'login_cls': TwineLogin
+            'login_cls': TwineLogin,
+            'namespace_support': False,
         }
     }
 
@@ -314,6 +348,11 @@ class CodeArtifactLogin(BasicCommand):
                          'domain',
             'required': False,
         },
+        {
+            'name': 'namespace',
+            'help_text': 'Associates a namespace with your repository tool',
+            'required': False,
+        },
         {
             'name': 'duration-seconds',
             'cli_type_name': 'integer',
@@ -337,6 +376,16 @@ class CodeArtifactLogin(BasicCommand):
         },
     ]
 
+    def _get_namespace(self, tool, parsed_args):
+        namespace_compatible = self.TOOL_MAP[tool]['namespace_support']
+
+        if not namespace_compatible and parsed_args.namespace:
+            raise ValueError(
+                'Argument --namespace is not supported for {}'.format(tool)
+            )
+        else:
+            return parsed_args.namespace
+
     def _get_repository_endpoint(
         self, codeartifact_client, parsed_args, package_format
     ):
@@ -385,10 +434,12 @@ def _run_main(self, parsed_args, parsed_globals):
             codeartifact_client, parsed_args, package_format
         )
 
+        namespace = self._get_namespace(tool, parsed_args)
+
         auth_token = auth_token_res['authorizationToken']
         expiration = parse_timestamp(auth_token_res['expiration'])
         login = self.TOOL_MAP[tool]['login_cls'](
-            auth_token, expiration, repository_endpoint, subprocess
+            auth_token, expiration, repository_endpoint, subprocess, namespace
         )
 
         login.login(parsed_args.dry_run)


Test patch:
diff --git a/tests/functional/codeartifact/test_codeartifact_login.py b/tests/functional/codeartifact/test_codeartifact_login.py
--- a/tests/functional/codeartifact/test_codeartifact_login.py
+++ b/tests/functional/codeartifact/test_codeartifact_login.py
@@ -29,6 +29,7 @@ def setUp(self):
         self.domain_owner = 'domain-owner'
         self.repository = 'repository'
         self.auth_token = 'auth-token'
+        self.namespace = 'namespace'
         self.duration = 3600
         self.expiration = time.time() + self.duration
         self.expiration_as_datetime = parse_timestamp(self.expiration)
@@ -51,7 +52,8 @@ def tearDown(self):
 
     def _setup_cmd(self, tool,
                    include_domain_owner=False, dry_run=False,
-                   include_duration_seconds=False):
+                   include_duration_seconds=False,
+                   include_namespace=False):
         package_format = CodeArtifactLogin.TOOL_MAP[tool]['package_format']
         self.endpoint = 'https://{domain}-{domainOwner}.codeartifact.aws.' \
             'a2z.com/{format}/{repository}/'.format(
@@ -75,6 +77,9 @@ def _setup_cmd(self, tool,
         if include_duration_seconds:
             cmdline += ' --duration-seconds %s' % self.duration
 
+        if include_namespace:
+            cmdline += ' --namespace %s' % self.namespace
+
         # Responses from calls to services.
         self.parsed_responses = [
             {"authorizationToken": self.auth_token,
@@ -84,7 +89,7 @@ def _setup_cmd(self, tool,
 
         return cmdline
 
-    def _get_npm_commands(self):
+    def _get_npm_commands(self, **kwargs):
         npm_cmd = 'npm.cmd' \
             if platform.system().lower() == 'windows' else 'npm'
 
@@ -96,9 +101,12 @@ def _get_npm_commands(self):
             repo_uri.netloc, repo_uri.path
         )
 
+        scope = kwargs.get('scope')
+        registry = '{}:registry'.format(scope) if scope else 'registry'
+
         commands = []
         commands.append(
-            [npm_cmd, 'config', 'set', 'registry', self.endpoint]
+            [npm_cmd, 'config', 'set', registry, self.endpoint]
         )
         commands.append(
             [npm_cmd, 'config', 'set', always_auth_config, 'true']
@@ -306,6 +314,26 @@ def test_npm_login_with_domain_owner_dry_run(self):
         )
         self._assert_dry_run_execution(self._get_npm_commands(), stdout)
 
+    def test_npm_login_with_namespace(self):
+        cmdline = self._setup_cmd(
+            tool='npm', include_namespace=True
+        )
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=0)
+        self._assert_operations_called(package_format='npm')
+        self._assert_expiration_printed_to_stdout(stdout)
+        self._assert_subprocess_execution(
+            self._get_npm_commands(scope='@{}'.format(self.namespace))
+        )
+
+    def test_npm_login_with_namespace_dry_run(self):
+        cmdline = self._setup_cmd(
+            tool='npm', include_namespace=True, dry_run=True
+        )
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=0)
+        self._assert_operations_called(package_format='npm')
+        self._assert_dry_run_execution(self._get_npm_commands(
+            scope='@{}'.format(self.namespace)), stdout)
+
     def test_pip_login_without_domain_owner(self):
         cmdline = self._setup_cmd(tool='pip')
         stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=0)
@@ -353,6 +381,23 @@ def test_pip_login_with_domain_owner_dry_run(self):
         )
         self._assert_dry_run_execution(self._get_pip_commands(), stdout)
 
+    def test_pip_login_with_namespace(self):
+        cmdline = self._setup_cmd(tool='pip', include_namespace=True)
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=255)
+        self._assert_operations_called(
+            package_format='pypi'
+        )
+        self.assertIn('Argument --namespace is not supported for pip', stderr)
+
+    def test_pip_login_with_namespace_dry_run(self):
+        cmdline = self._setup_cmd(
+            tool='pip', include_namespace=True, dry_run=True)
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=255)
+        self._assert_operations_called(
+            package_format='pypi'
+        )
+        self.assertIn('Argument --namespace is not supported for pip', stderr)
+
     def test_twine_login_without_domain_owner(self):
         cmdline = self._setup_cmd(tool='twine')
         stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=0)
@@ -442,3 +487,26 @@ def test_twine_login_with_domain_owner_dry_run(self):
             username='aws',
             password=self.auth_token
         )
+
+    def test_twine_login_with_namespace(self):
+        cmdline = self._setup_cmd(
+            tool='twine', include_namespace=True
+        )
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=255)
+        self._assert_operations_called(
+            package_format='pypi'
+        )
+        self.assertIn(
+            'Argument --namespace is not supported for twine', stderr)
+
+    def test_twine_login_with_namespace_dry_run(self):
+        cmdline = self._setup_cmd(
+            tool='twine', include_namespace=True, dry_run=True
+        )
+        stdout, stderr, rc = self.run_cmd(cmdline, expected_rc=255)
+        self._assert_operations_called(
+            package_format='pypi',
+        )
+        self.assertFalse(os.path.exists(self.test_pypi_rc_path))
+        self.assertIn(
+            'Argument --namespace is not supported for twine', stderr)
diff --git a/tests/unit/customizations/codeartifact/test_adapter_login.py b/tests/unit/customizations/codeartifact/test_adapter_login.py
--- a/tests/unit/customizations/codeartifact/test_adapter_login.py
+++ b/tests/unit/customizations/codeartifact/test_adapter_login.py
@@ -74,6 +74,7 @@ def setUp(self):
         self.package_format = 'npm'
         self.repository = 'repository'
         self.auth_token = 'auth-token'
+        self.namespace = 'namespace'
         self.expiration = (datetime.now(tzlocal()) + relativedelta(hours=10)
                            + relativedelta(minutes=9)).replace(microsecond=0)
         self.endpoint = 'https://{domain}-{domainOwner}.codeartifact.aws.' \
@@ -122,12 +123,38 @@ def test_login(self):
             expected_calls, any_order=True
         )
 
+    def test_get_scope(self):
+        expected_value = '@{}'.format(self.namespace)
+        scope = self.test_subject.get_scope(self.namespace)
+        self.assertEqual(scope, expected_value)
+
+    def test_get_scope_none_namespace(self):
+        expected_value = None
+        scope = self.test_subject.get_scope(None)
+        self.assertEqual(scope, expected_value)
+
+    def test_get_scope_invalid_name(self):
+        with self.assertRaises(ValueError):
+            self.test_subject.get_scope('.{}'.format(self.namespace))
+
+    def test_get_scope_without_prefix(self):
+        expected_value = '@{}'.format(self.namespace)
+        scope = self.test_subject.get_scope('@{}'.format(self.namespace))
+        self.assertEqual(scope, expected_value)
+
     def test_get_commands(self):
         commands = self.test_subject.get_commands(
             self.endpoint, self.auth_token
         )
         self.assertCountEqual(commands, self.commands)
 
+    def test_get_commands_with_scope(self):
+        commands = self.test_subject.get_commands(
+            self.endpoint, self.auth_token, scope=self.namespace
+        )
+        self.commands[0][3] = '{}:registry'.format(self.namespace)
+        self.assertCountEqual(commands, self.commands)
+
     def test_login_dry_run(self):
         self.test_subject.login(dry_run=True)
         self.subprocess_utils.check_call.assert_not_called()