Repository: codemagic-ci-cd/cli-tools (language: python)

Issue:
Update docs for `app-store-connect`
Follow up to #386 to update markdown documentation for `app-store-connect` actions.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cde2c30c..38a7b55b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,39 @@
+Version 0.49.0
+-------------
+
+This release contains changes from [PR #386](https://github.com/codemagic-ci-cd/cli-tools/pull/386) and [PR #387](https://github.com/codemagic-ci-cd/cli-tools/pull/387).
+
+**Features**
+- Unify `app-store-connect` command line API experience by gathering similar actions under respective action groups.
+- New action groups were added to group similar actions together:
+  - `app-store-connect bundle-ids`,
+  - `app-store-connect certificates`,
+  - `app-store-connect devices`,
+  - `app-store-connect profiles`.
+
+**Deprecations**
+The following actions are deprecated and show a warning message when invoked:
+  - `app-store-connect list-builds` (replaced by `app-store-connect builds list`),
+  - `app-store-connect create-bundle-id` (replaced by `app-store-connect bundle-ids create`),
+  - `app-store-connect delete-bundle-id` (replaced by `app-store-connect bundle-ids delete`),
+  - `app-store-connect get-bundle-id` (replaced by `app-store-connect bundle-ids get`),
+  - `app-store-connect list-bundle-id-profiles` (replaced by `app-store-connect bundle-ids profiles`),
+  - `app-store-connect list-bundle-ids` (replaced by `app-store-connect bundle-ids list`),
+  - `app-store-connect create-certificate` (replaced by `app-store-connect certificates create`),
+  - `app-store-connect delete-certificate` (replaced by `app-store-connect certificates delete`),
+  - `app-store-connect get-certificate` (replaced by `app-store-connect certificates get`),
+  - `app-store-connect list-certificates` (replaced by `app-store-connect certificates list`),
+  - `app-store-connect list-devices` (replaced by `app-store-connect devices list`),
+  - `app-store-connect register-device` (replaced by `app-store-connect devices register`),
+  - `app-store-connect create-profile` (replaced by `app-store-connect profiles create`),
+  - `app-store-connect delete-profile` (replaced by `app-store-connect profiles delete`),
+  - `app-store-connect get-profile` (replaced by `app-store-connect profiles get`),
+  - `app-store-connect list-profiles` (replaced by `app-store-connect profiles list`).
+
+**Development**
+- Decorator `@action` signature was changed. Optional keyword argument `deprecated_alias: str` was replaced by optional `deprecation_info: ActionDeprecationInfo` which holds both version in which the action was deprecated, and the deprecated name of the action.
+- Decorator `@action` definition was moved from `codemagic.cli.cli_app` to `codemagic.cli.action`. It is still accessible from package `codemagic.cli` as before.
+
 Version 0.48.2
 -------------
 
diff --git a/pyproject.toml b/pyproject.toml
index c4fe915e..fde010be 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "codemagic-cli-tools"
-version = "0.48.2"
+version = "0.49.0"
 description = "CLI tools used in Codemagic builds"
 readme = "README.md"
 authors = [
diff --git a/src/codemagic/__version__.py b/src/codemagic/__version__.py
index 94adcb25..57e2550b 100644
--- a/src/codemagic/__version__.py
+++ b/src/codemagic/__version__.py
@@ -1,5 +1,5 @@
 __title__ = "codemagic-cli-tools"
 __description__ = "CLI tools used in Codemagic builds"
-__version__ = "0.48.2.dev"
+__version__ = "0.49.0.dev"
 __url__ = "https://github.com/codemagic-ci-cd/cli-tools"
 __licence__ = "GNU General Public License v3.0"
diff --git a/src/codemagic/cli/__init__.py b/src/codemagic/cli/__init__.py
index 36b7dc1d..74cc8b4c 100644
--- a/src/codemagic/cli/__init__.py
+++ b/src/codemagic/cli/__init__.py
@@ -1,3 +1,5 @@
+from .action import ActionDeprecationInfo
+from .action import action
 from .action_group import ActionGroup
 from .action_group import ActionGroupProperties
 from .argument import Argument
@@ -7,7 +9,6 @@
 from .argument import TypedCliArgument
 from .cli_app import CliApp
 from .cli_app import CliAppException
-from .cli_app import action
 from .cli_app import common_arguments
 from .cli_process import CliProcess
 from .cli_types import CommandArg
diff --git a/src/codemagic/cli/action.py b/src/codemagic/cli/action.py
new file mode 100644
index 00000000..df66d40b
--- /dev/null
+++ b/src/codemagic/cli/action.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import dataclasses
+import sys
+from functools import wraps
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Callable
+from typing import Dict
+from typing import Optional
+from typing import TypeVar
+
+from .colors import Colors
+
+if TYPE_CHECKING:
+    from .action_group import ActionGroup
+    from .argument import Argument
+    from .cli_app import CliApp
+
+
+_Fn = TypeVar("_Fn", bound=Callable[..., object])
+
+
+@dataclasses.dataclass(frozen=True)
+class ActionDeprecationInfo:
+    alias: str
+    version: str
+
+    def get_message(self, replacement_action_name: str, *colors: Colors) -> str:
+        executable_name, _ = replacement_action_name.split(maxsplit=1)
+        deprecated_action_name = f"{executable_name} {self.alias}"
+        deprecation_message = (
+            f"Action `{deprecated_action_name}` was deprecated in version {self.version} "
+            f"and replaced by equivalent action `{replacement_action_name}`.\n"
+            f"Use `{replacement_action_name}` instead as `{deprecated_action_name}` "
+            f"is subject for removal in future releases."
+        )
+        return Colors.apply(deprecation_message, *colors)
+
+
+def action(
+    action_name: str,
+    *arguments: Argument,
+    action_group: Optional[ActionGroup] = None,
+    action_options: Optional[Dict[str, Any]] = None,
+    deprecation_info: Optional[ActionDeprecationInfo] = None,
+) -> Callable[[_Fn], _Fn]:
+    """
+    Decorator to mark that the method is usable from CLI
+    :param action_name: Name of the CLI parameter
+    :param arguments: CLI arguments that are required for this method to work
+    :param action_group: CLI argument group under which this action belongs
+    :param action_options: Meta information about the action to check whether some conditions are met
+    :param deprecation_info: Name and version pair describing the deprecated name of the action for
+                             backwards compatibility, and in which version it was deprecated.
+                             The action is registered on the root arguments parser with this name
+                             without explicit documentation.
+    """
+
+    # Ensure that each argument is used exactly once
+    unique_arguments = set()
+    function_cli_arguments = []
+    for argument in arguments:
+        argument_name = f"{argument.__class__.__name__}.{argument.name}"
+        if argument_name not in unique_arguments:
+            function_cli_arguments.append(argument)
+            unique_arguments.add(argument_name)
+
+    def decorator(func):
+        if func.__doc__ is None:
+            raise RuntimeError(f'Action "{action_name}" defined by {func} is not documented')
+        func.action_group = action_group
+        func.action_name = action_name
+        func.arguments = function_cli_arguments
+        func.is_cli_action = True
+        func.deprecation_info = deprecation_info
+        func.action_options = action_options or {}
+
+        @wraps(func)
+        def wrapper(self: CliApp, *args, **kwargs):
+            if deprecation_info and deprecation_info.alias in sys.argv:
+                _notify_deprecated_action_usage(self, action_name, action_group, deprecation_info)
+            return func(self, *args, **kwargs)
+
+        return wrapper
+
+    return decorator
+
+
+def _notify_deprecated_action_usage(
+    cli_app: CliApp,
+    action_name: str,
+    action_group: Optional[ActionGroup],
+    deprecation_info: ActionDeprecationInfo,
+):
+    executable = cli_app.get_executable_name()
+    name_parts = (executable, action_group.name if action_group else None, action_name)
+    full_action_name = " ".join(p for p in name_parts if p)
+    cli_app.echo(deprecation_info.get_message(full_action_name, Colors.YELLOW, Colors.BOLD))
diff --git a/src/codemagic/cli/argument/__init__.py b/src/codemagic/cli/argument/__init__.py
index f9bca22a..ce81ab01 100644
--- a/src/codemagic/cli/argument/__init__.py
+++ b/src/codemagic/cli/argument/__init__.py
@@ -1,4 +1,5 @@
 from .action_callable import ActionCallable
+from .action_callable import DeprecatedActionCallable
 from .argument import Argument
 from .argument_formatter import ArgumentFormatter
 from .argument_parser_builder import ArgumentParserBuilder
diff --git a/src/codemagic/cli/argument/action_callable.py b/src/codemagic/cli/argument/action_callable.py
index 63760365..36b54a56 100644
--- a/src/codemagic/cli/argument/action_callable.py
+++ b/src/codemagic/cli/argument/action_callable.py
@@ -9,6 +9,7 @@
 from typing import Sequence
 
 if TYPE_CHECKING:
+    from ..action import ActionDeprecationInfo
     from ..action_group import ActionGroup
     from .argument import Argument
 
@@ -19,6 +20,10 @@ class ActionCallable:
     arguments: Sequence[Argument]
     is_cli_action: bool
     action_options: Dict[str, Any]
-    deprecated_alias: Optional[str]
+    deprecation_info: Optional[ActionDeprecationInfo]
     __name__: str
     __call__: Callable
+
+
+class DeprecatedActionCallable(ActionCallable):
+    deprecation_info: ActionDeprecationInfo
diff --git a/src/codemagic/cli/argument/argument_parser_builder.py b/src/codemagic/cli/argument/argument_parser_builder.py
index d5720273..fb224ac3 100644
--- a/src/codemagic/cli/argument/argument_parser_builder.py
+++ b/src/codemagic/cli/argument/argument_parser_builder.py
@@ -39,11 +39,16 @@ def __init__(
 
     def _create_action_parser(self, parent_parser: SubParsersAction, for_deprecated_alias: bool):
         if for_deprecated_alias:
-            if self._cli_action.deprecated_alias is None:
+            if self._cli_action.deprecation_info is None:
                 raise RuntimeError(f"Deprecated alias requested for {self._cli_action.action_name} without alias")
-            deprecation_message = Colors.YELLOW(Colors.BOLD(f"Deprecated alias for `{self._full_action_name}`."))
+
+            deprecation_message = self._cli_action.deprecation_info.get_message(
+                self._full_action_name,
+                Colors.YELLOW,
+                Colors.BOLD,
+            )
             return parent_parser.add_parser(
-                self._cli_action.deprecated_alias,
+                self._cli_action.deprecation_info.alias,
                 formatter_class=CliHelpFormatter,
                 description=f"{deprecation_message} {Colors.BOLD(self._cli_action.__doc__)}",
             )
diff --git a/src/codemagic/cli/cli_app.py b/src/codemagic/cli/cli_app.py
index 6d63a1c2..42124d08 100644
--- a/src/codemagic/cli/cli_app.py
+++ b/src/codemagic/cli/cli_app.py
@@ -12,7 +12,6 @@
 import shutil
 import sys
 import time
-from functools import wraps
 from itertools import chain
 from typing import TYPE_CHECKING
 from typing import Any
@@ -36,6 +35,7 @@
 from .argument import ActionCallable
 from .argument import Argument
 from .argument import ArgumentParserBuilder
+from .argument import DeprecatedActionCallable
 from .cli_help_formatter import CliHelpFormatter
 from .cli_process import CliProcess
 from .cli_types import CommandArg
@@ -173,7 +173,7 @@ def _get_invoked_cli_action(self, args: argparse.Namespace) -> ActionCallable:
         try:
             return cli_actions[action_key]
         except KeyError:
-            deprecated_cli_actions = {ac.deprecated_alias: ac for ac in self.iter_deprecated_cli_actions()}
+            deprecated_cli_actions = {ac.deprecation_info.alias: ac for ac in self.iter_deprecated_cli_actions()}
             return deprecated_cli_actions[action_key]
 
     def _invoke_action(self, args: argparse.Namespace):
@@ -320,9 +320,9 @@ def iter_cli_actions(self, action_group: Optional[ActionGroup] = None) -> Iterab
         for class_action in self.iter_class_cli_actions(action_group=action_group):
             yield getattr(self, class_action.__name__)
 
-    def iter_deprecated_cli_actions(self) -> Iterable[ActionCallable]:
+    def iter_deprecated_cli_actions(self) -> Iterable[DeprecatedActionCallable]:
         for class_action in self.iter_class_cli_actions(include_all=True):
-            if class_action.deprecated_alias:
+            if class_action.deprecation_info:
                 yield getattr(self, class_action.__name__)
 
     @classmethod
@@ -385,12 +385,12 @@ def _register_cli_action(
         deprecated_action_parsers: SubParsersAction,
     ):
         ArgumentParserBuilder(cls, main_or_group_action, current_action_parsers).build()
-        if not main_or_group_action.deprecated_alias:
+        if not main_or_group_action.deprecation_info:
             return
 
         # Also register the deprecated alias
         ArgumentParserBuilder(cls, main_or_group_action, deprecated_action_parsers, for_deprecated_alias=True).build()
-        CliHelpFormatter.suppress_deprecated_action(main_or_group_action.deprecated_alias)
+        CliHelpFormatter.suppress_deprecated_action(main_or_group_action.deprecation_info.alias)
 
     def _obfuscate_command(
         self,
@@ -449,80 +449,11 @@ def execute(
         ).execute(**execute_kwargs)
 
 
-_Fn = TypeVar("_Fn", bound=Callable[..., object])
-
-
-def action(
-    action_name: str,
-    *arguments: Argument,
-    action_group: Optional[ActionGroup] = None,
-    action_options: Optional[Dict[str, Any]] = None,
-    deprecated_alias: Optional[str] = None,
-) -> Callable[[_Fn], _Fn]:
-    """
-    Decorator to mark that the method is usable from CLI
-    :param action_name: Name of the CLI parameter
-    :param arguments: CLI arguments that are required for this method to work
-    :param action_group: CLI argument group under which this action belongs
-    :param action_options: Meta information about the action to check whether some conditions are met
-    :param deprecated_alias: Deprecated name of the action for backwards compatibility.
-                             The action is registered on the root arguments parser with this name
-                             without explicit documentation.
-    """
-
-    # Ensure that each argument is used exactly once
-    unique_arguments = set()
-    function_cli_arguments = []
-    for argument in arguments:
-        argument_name = f"{argument.__class__.__name__}.{argument.name}"
-        if argument_name not in unique_arguments:
-            function_cli_arguments.append(argument)
-            unique_arguments.add(argument_name)
-
-    def decorator(func):
-        if func.__doc__ is None:
-            raise RuntimeError(f'Action "{action_name}" defined by {func} is not documented')
-        func.action_group = action_group
-        func.action_name = action_name
-        func.arguments = function_cli_arguments
-        func.is_cli_action = True
-        func.deprecated_alias = deprecated_alias
-        func.action_options = action_options or {}
-
-        @wraps(func)
-        def wrapper(self: CliApp, *args, **kwargs):
-            if deprecated_alias and deprecated_alias in sys.argv:
-                _notify_deprecated_action_usage(self, action_name, action_group, deprecated_alias)
-            return func(self, *args, **kwargs)
-
-        return wrapper
-
-    return decorator
-
-
-def _notify_deprecated_action_usage(
-    cli_app: CliApp,
-    action_name: str,
-    action_group: Optional[ActionGroup],
-    deprecated_alias: str,
-):
-    executable = cli_app.get_executable_name()
-    name_parts = (executable, action_group.name if action_group else None, action_name)
-    full_action_name = " ".join(p for p in name_parts if p)
-    deprecated_action_name = f"{executable} {deprecated_alias}"
-    deprecation_message = (
-        f"Using `{deprecated_action_name}` is deprecated and replaced by equivalent action `{full_action_name}`.\n"
-        f"Use `{full_action_name}` instead as `{deprecated_action_name}` is subject for removal in future releases.\n"
-    )
-    cli_app.echo(Colors.apply(deprecation_message, Colors.YELLOW, Colors.BOLD))
-
-
 _CliApp = TypeVar("_CliApp", bound=Type[CliApp])
 
 
 def common_arguments(*class_arguments: Argument) -> Callable[[_CliApp], _CliApp]:
     """
-    Decorator to mark that the method is usable from CLI
     :param class_arguments: CLI arguments that are required to initialize the class
     """
 
diff --git a/src/codemagic/tools/_app_store_connect/__init__.py b/src/codemagic/tools/_app_store_connect/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/codemagic/tools/app_store_connect.py b/src/codemagic/tools/app_store_connect.py
deleted file mode 100755
index eef57e34..00000000
--- a/src/codemagic/tools/app_store_connect.py
+++ /dev/null
@@ -1,1091 +0,0 @@
-#!/usr/bin/env python3
-
-from __future__ import annotations
-
-import argparse
-import pathlib
-import re
-import tempfile
-import time
-from functools import lru_cache
-from itertools import chain
-from typing import Iterator
-from typing import List
-from typing import Optional
-from typing import Sequence
-from typing import Set
-from typing import Tuple
-from typing import Union
-from typing import cast
-
-import jwt
-
-from codemagic import cli
-from codemagic.apple import AppStoreConnectApiError
-from codemagic.apple.app_store_connect import AppStoreConnectApiClient
-from codemagic.apple.app_store_connect import IssuerId
-from codemagic.apple.app_store_connect import KeyIdentifier
-from codemagic.apple.resources import BetaReviewState
-from codemagic.apple.resources import Build
-from codemagic.apple.resources import BuildProcessingState
-from codemagic.apple.resources import BundleId
-from codemagic.apple.resources import BundleIdPlatform
-from codemagic.apple.resources import CertificateType
-from codemagic.apple.resources import Device
-from codemagic.apple.resources import DeviceStatus
-from codemagic.apple.resources import Profile
-from codemagic.apple.resources import ProfileState
-from codemagic.apple.resources import ProfileType
-from codemagic.apple.resources import ResourceId
-from codemagic.apple.resources import SigningCertificate
-from codemagic.cli import Argument
-from codemagic.cli import Colors
-from codemagic.mixins import PathFinderMixin
-from codemagic.models import Certificate
-from codemagic.models import PrivateKey
-from codemagic.models import ProvisioningProfile
-from codemagic.utilities import log
-
-from ._app_store_connect.action_groups import AppsActionGroup
-from ._app_store_connect.action_groups import AppStoreVersionLocalizationsActionGroup
-from ._app_store_connect.action_groups import AppStoreVersionsActionGroup
-from ._app_store_connect.action_groups import AppStoreVersionSubmissionsActionGroup
-from ._app_store_connect.action_groups import BetaAppReviewSubmissionsActionGroup
-from ._app_store_connect.action_groups import BetaBuildLocalizationsActionGroup
-from ._app_store_connect.action_groups import BetaGroupsActionGroup
-from ._app_store_connect.action_groups import BuildsActionGroup
-from ._app_store_connect.action_groups import ReviewSubmissionItemsActionGroup
-from ._app_store_connect.action_groups import ReviewSubmissionsActionGroup
-from ._app_store_connect.actions import GetLatestAppStoreBuildNumberAction
-from ._app_store_connect.actions import GetLatestBuildNumberAction
-from ._app_store_connect.actions import GetLatestTestflightBuildNumberAction
-from ._app_store_connect.actions import PublishAction
-from ._app_store_connect.arguments import AppArgument
-from ._app_store_connect.arguments import AppStoreConnectArgument
-from ._app_store_connect.arguments import AppStoreVersionInfo  # noqa: F401
-from ._app_store_connect.arguments import AppStoreVersionLocalizationInfo  # noqa: F401
-from ._app_store_connect.arguments import ArgumentGroups
-from ._app_store_connect.arguments import BetaBuildInfo  # noqa: F401
-from ._app_store_connect.arguments import BuildArgument
-from ._app_store_connect.arguments import BundleIdArgument
-from ._app_store_connect.arguments import CertificateArgument
-from ._app_store_connect.arguments import CommonArgument
-from ._app_store_connect.arguments import DeviceArgument
-from ._app_store_connect.arguments import ProfileArgument
-from ._app_store_connect.arguments import PublishArgument  # noqa: F401
-from ._app_store_connect.arguments import Types
-from ._app_store_connect.errors import AppStoreConnectError
-from ._app_store_connect.resource_manager_mixin import ResourceManagerMixin
-from ._app_store_connect.resource_printer import ResourcePrinter
-
-
-def _get_certificate_key(
-    certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
-    certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
-) -> Optional[PrivateKey]:
-    if isinstance(certificate_key, PrivateKey):
-        return certificate_key
-    password = certificate_key_password.value if certificate_key_password else None
-    if certificate_key is not None:
-        try:
-            return PrivateKey.from_buffer(certificate_key.value, password)
-        except ValueError:
-            CertificateArgument.PRIVATE_KEY.raise_argument_error("Not a valid certificate private key")
-    return None
-
-
-@cli.common_arguments(*AppStoreConnectArgument)
-class AppStoreConnect(
-    cli.CliApp,
-    AppStoreVersionSubmissionsActionGroup,
-    AppStoreVersionsActionGroup,
-    AppStoreVersionLocalizationsActionGroup,
-    AppsActionGroup,
-    BetaAppReviewSubmissionsActionGroup,
-    BetaBuildLocalizationsActionGroup,
-    ReviewSubmissionsActionGroup,
-    ReviewSubmissionItemsActionGroup,
-    BetaGroupsActionGroup,
-    BuildsActionGroup,
-    GetLatestAppStoreBuildNumberAction,
-    GetLatestBuildNumberAction,
-    GetLatestTestflightBuildNumberAction,
-    PublishAction,
-    ResourceManagerMixin,
-    PathFinderMixin,
-):
-    """
-    Interact with Apple services via App Store Connect API
-    """
-
-    def __init__(
-        self,
-        key_identifier: Optional[KeyIdentifier],
-        issuer_id: Optional[IssuerId],
-        private_key: Optional[str],
-        log_requests: bool = False,
-        unauthorized_request_retries: int = 1,
-        server_error_retries: int = 1,
-        enable_jwt_cache: bool = False,
-        json_output: bool = False,
-        profiles_directory: pathlib.Path = ProvisioningProfile.DEFAULT_LOCATION,
-        certificates_directory: pathlib.Path = Certificate.DEFAULT_LOCATION,
-        **kwargs,
-    ):
-        super().__init__(**kwargs)
-        self.profiles_directory = profiles_directory
-        self.certificates_directory = certificates_directory
-        self.printer = ResourcePrinter(bool(json_output), self.echo)
-        self._key_identifier = key_identifier
-        self._issuer_id = issuer_id
-        self._private_key = private_key
-        self._log_requests = log_requests
-        self._unauthorized_request_retries = unauthorized_request_retries
-        self._server_error_retries = server_error_retries
-        self._enable_jwt_cache = enable_jwt_cache
-
-    @classmethod
-    def from_cli_args(cls, cli_args: argparse.Namespace) -> AppStoreConnect:
-        key_identifier_argument = AppStoreConnectArgument.KEY_IDENTIFIER.from_args(cli_args)
-        issuer_id_argument = AppStoreConnectArgument.ISSUER_ID.from_args(cli_args)
-        private_key_argument = AppStoreConnectArgument.PRIVATE_KEY.from_args(cli_args)
-        unauthorized_request_retries = Types.ApiUnauthorizedRetries.resolve_value(cli_args.unauthorized_request_retries)
-        server_error_retries = Types.ApiServerErrorRetries.resolve_value(cli_args.server_error_retries)
-        disable_jwt_cache = AppStoreConnectArgument.DISABLE_JWT_CACHE.from_args(cli_args)
-
-        app_store_connect = AppStoreConnect(
-            key_identifier=key_identifier_argument.value if key_identifier_argument else None,
-            issuer_id=issuer_id_argument.value if issuer_id_argument else None,
-            private_key=private_key_argument.value if private_key_argument else None,
-            log_requests=cli_args.log_requests,
-            unauthorized_request_retries=unauthorized_request_retries,
-            server_error_retries=server_error_retries,
-            enable_jwt_cache=not disable_jwt_cache,
-            json_output=cli_args.json_output,
-            profiles_directory=cli_args.profiles_directory,
-            certificates_directory=cli_args.certificates_directory,
-            **cls._parent_class_kwargs(cli_args),
-        )
-
-        cli_action = app_store_connect._get_invoked_cli_action(cli_args)
-        if cli_action.action_options.get("requires_api_client", True):
-            app_store_connect._assert_api_client_credentials()
-
-        return app_store_connect
-
-    def _assert_api_client_credentials(self, custom_error: Optional[str] = None):
-        if self._issuer_id is None:
-            if custom_error:
-                default_message = AppStoreConnectArgument.ISSUER_ID.get_missing_value_error_message()
-                raise AppStoreConnectArgument.ISSUER_ID.raise_argument_error(f"{default_message}. {custom_error}")
-            else:
-                raise AppStoreConnectArgument.ISSUER_ID.raise_argument_error()
-
-        if self._key_identifier is None:
-            if custom_error:
-                default_message = AppStoreConnectArgument.KEY_IDENTIFIER.get_missing_value_error_message()
-                raise AppStoreConnectArgument.KEY_IDENTIFIER.raise_argument_error(f"{default_message}. {custom_error}")
-            else:
-                raise AppStoreConnectArgument.KEY_IDENTIFIER.raise_argument_error()
-
-        try:
-            self._resolve_app_store_connect_private_key()
-        except ValueError as ve:
-            custom_error = ve.args[0] if ve.args else custom_error
-            if custom_error:
-                default_message = AppStoreConnectArgument.PRIVATE_KEY.get_missing_value_error_message()
-                AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error(f"{default_message}. {custom_error}")
-            else:
-                AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error()
-
-    def _resolve_app_store_connect_private_key(self):
-        if self._private_key is not None:
-            return
-
-        for keys_path in Types.PrivateKeyArgument.PRIVATE_KEY_LOCATIONS:
-            try:
-                api_key = next(keys_path.expanduser().glob(f"AuthKey_{self._key_identifier}.p8"))
-            except StopIteration:
-                continue
-
-            try:
-                private_key_argument = Types.PrivateKeyArgument(api_key.read_text())
-            except ValueError:
-                raise ValueError(f"Provided value in {api_key} is not valid")
-            self._private_key = private_key_argument.value
-            break
-        else:
-            raise ValueError()
-
-    def _validate_api_client_key(self, client: AppStoreConnectApiClient):
-        """
-        When running from a CLI context, ensure that App Store Connect API client is using valid
-        private key for JWT generation. In case of invalid key exit with descriptive argument error.
-        """
-
-        if not self.is_cli_invocation():
-            return
-
-        try:
-            client.generate_auth_headers()
-        except jwt.InvalidKeyError:
-            log.get_file_logger(self.__class__).exception("Invalid App Store Connect API key")
-            asc_docs_base_url = "https://developer.apple.com/documentation/appstoreconnectapi"
-            error_message = (
-                "Invalid App Store Connect API key. Make sure to use the private API key downloaded from "
-                "App Store Connect. Read more about creating App Store Connect API keys from "
-                f"{asc_docs_base_url}/creating_api_keys_for_app_store_connect_api"
-            )
-            AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error(error_message)
-
-    @lru_cache(1)
-    def _get_api_client(self) -> AppStoreConnectApiClient:
-        assert self._key_identifier is not None
-        assert self._issuer_id is not None
-        assert self._private_key is not None
-        client = AppStoreConnectApiClient(
-            self._key_identifier,
-            self._issuer_id,
-            self._private_key,
-            log_requests=self._log_requests,
-            unauthorized_request_retries=self._unauthorized_request_retries,
-            server_error_retries=self._server_error_retries,
-            enable_jwt_cache=self._enable_jwt_cache,
-        )
-        self._validate_api_client_key(client)
-        return client
-
-    @property
-    def api_client(self) -> AppStoreConnectApiClient:
-        return self._get_api_client()
-
-    @cli.action(
-        "list-builds",
-        AppArgument.APPLICATION_ID_RESOURCE_ID_OPTIONAL,
-        *ArgumentGroups.LIST_BUILDS_FILTERING_ARGUMENTS,
-    )
-    def list_builds(
-        self,
-        application_id: Optional[ResourceId] = None,
-        expired: Optional[bool] = None,
-        not_expired: Optional[bool] = None,
-        build_id: Optional[ResourceId] = None,
-        pre_release_version: Optional[str] = None,
-        processing_state: Optional[BuildProcessingState] = None,
-        beta_review_state: Optional[Union[BetaReviewState, Sequence[BetaReviewState]]] = None,
-        build_version_number: Optional[int] = None,
-        should_print: bool = True,
-    ) -> List[Build]:
-        """
-        List Builds from Apple Developer Portal matching given constraints
-        """
-        try:
-            expired_value = Argument.resolve_optional_two_way_switch(expired, not_expired)
-        except ValueError:
-            flags = f"{BuildArgument.EXPIRED.flag!r} and {BuildArgument.NOT_EXPIRED.flag!r}"
-            raise BuildArgument.NOT_EXPIRED.raise_argument_error(f"Using mutually exclusive switches {flags}.")
-
-        builds_filter = self.api_client.builds.Filter(
-            app=application_id,
-            expired=expired_value,
-            id=build_id,
-            processing_state=processing_state,
-            beta_app_review_submission_beta_review_state=beta_review_state,
-            version=build_version_number,
-            pre_release_version_version=pre_release_version,
-        )
-        return self._list_resources(builds_filter, self.api_client.builds, should_print)
-
-    @cli.action(
-        "list-devices",
-        BundleIdArgument.PLATFORM_OPTIONAL,
-        DeviceArgument.DEVICE_NAME_OPTIONAL,
-        DeviceArgument.DEVICE_STATUS,
-    )
-    def list_devices(
-        self,
-        platform: Optional[BundleIdPlatform] = None,
-        device_name: Optional[str] = None,
-        device_status: Optional[DeviceStatus] = None,
-        should_print: bool = True,
-    ) -> List[Device]:
-        """
-        List Devices from Apple Developer portal matching given constraints
-        """
-
-        device_filter = self.api_client.devices.Filter(
-            name=device_name,
-            platform=platform,
-            status=device_status,
-        )
-        return self._list_resources(device_filter, self.api_client.devices, should_print)
-
-    @cli.action(
-        "register-device",
-        BundleIdArgument.PLATFORM,
-        DeviceArgument.DEVICE_NAME,
-        DeviceArgument.DEVICE_UDIDS,
-        DeviceArgument.IGNORE_REGISTRATION_ERRORS,
-    )
-    def register_device(
-        self,
-        platform: BundleIdPlatform,
-        device_name: str,
-        device_udid: Optional[str] = None,  # Deprecated in https://github.com/codemagic-ci-cd/cli-tools/pull/331
-        device_udids: Optional[
-            Union[
-                str,
-                Types.DeviceUdidsArgument,
-                Sequence[Union[str, Types.DeviceUdidsArgument]],
-            ]
-        ] = None,
-        ignore_registration_errors: bool = DeviceArgument.IGNORE_REGISTRATION_ERRORS.get_default(),
-        should_print: bool = True,
-    ) -> List[Device]:
-        """
-        Register new Devices for app development
-        """
-        if not device_udids and not device_udid:
-            DeviceArgument.DEVICE_UDIDS.raise_argument_error("At least one device UDID is required")
-
-        if device_udids is None:
-            device_udids = []
-        if isinstance(device_udids, str) or isinstance(device_udids, Types.DeviceUdidsArgument):
-            device_udids = [device_udids]
-
-        if device_udid:
-            device_udids = [device_udid, *device_udids]
-
-        device_udids_values: List[str] = []
-        for device_udids in device_udids:
-            if isinstance(device_udids, Types.DeviceUdidsArgument):
-                device_udids_values.extend(device_udids.value)
-            else:
-                device_udids_values.append(device_udids)
-
-        registered_devices = []
-        for device_udid in device_udids_values:
-            try:
-                device = self._create_resource(
-                    self.api_client.devices,
-                    should_print,
-                    udid=device_udid,
-                    name=device_name,
-                    platform=platform,
-                )
-            except AppStoreConnectError as error:
-                if not ignore_registration_errors:
-                    raise error
-                self.logger.error(Colors.YELLOW(f"Failed to register a device: {error.args[0]}"))
-            else:
-                registered_devices.append(device)
-
-            self.echo("") if should_print else None
-
-        return registered_devices
-
-    @cli.action(
-        "create-bundle-id",
-        BundleIdArgument.BUNDLE_ID_IDENTIFIER,
-        BundleIdArgument.BUNDLE_ID_NAME,
-        BundleIdArgument.PLATFORM,
-    )
-    def create_bundle_id(
-        self,
-        bundle_id_identifier: str,
-        bundle_id_name: Optional[str] = None,
-        platform: BundleIdPlatform = BundleIdPlatform.IOS,
-        should_print: bool = True,
-    ) -> BundleId:
-        """
-        Create Bundle ID in Apple Developer portal for specifier identifier
-        """
-
-        if bundle_id_name is None:
-            bundle_id_name = bundle_id_identifier.replace(".", " ")
-
-        create_params = dict(identifier=bundle_id_identifier, name=bundle_id_name, platform=platform)
-        return self._create_resource(self.api_client.bundle_ids, should_print, **create_params)
-
-    @cli.action(
-        "list-bundle-ids",
-        BundleIdArgument.BUNDLE_ID_IDENTIFIER_OPTIONAL,
-        BundleIdArgument.BUNDLE_ID_NAME,
-        BundleIdArgument.PLATFORM_OPTIONAL,
-        BundleIdArgument.IDENTIFIER_STRICT_MATCH,
-    )
-    def list_bundle_ids(
-        self,
-        bundle_id_identifier: Optional[str] = None,
-        bundle_id_name: Optional[str] = None,
-        platform: Optional[BundleIdPlatform] = None,
-        bundle_id_identifier_strict_match: bool = False,
-        should_print: bool = True,
-    ) -> List[BundleId]:
-        """
-        List Bundle IDs from Apple Developer portal matching given constraints
-        """
-
-        def predicate(bundle_id):
-            return bundle_id.attributes.identifier == bundle_id_identifier
-
-        bundle_id_filter = self.api_client.bundle_ids.Filter(
-            identifier=bundle_id_identifier,
-            name=bundle_id_name,
-            platform=platform,
-        )
-        bundle_ids = self._list_resources(
-            bundle_id_filter,
-            self.api_client.bundle_ids,
-            should_print,
-            filter_predicate=predicate if bundle_id_identifier_strict_match else None,
-        )
-
-        return bundle_ids
-
-    @cli.action("get-bundle-id", BundleIdArgument.BUNDLE_ID_RESOURCE_ID)
-    def get_bundle_id(
-        self,
-        bundle_id_resource_id: ResourceId,
-        should_print: bool = True,
-    ) -> BundleId:
-        """
-        Get specified Bundle ID from Apple Developer portal
-        """
-
-        return self._get_resource(bundle_id_resource_id, self.api_client.bundle_ids, should_print)
-
-    @cli.action(
-        "delete-bundle-id",
-        BundleIdArgument.BUNDLE_ID_RESOURCE_ID,
-        CommonArgument.IGNORE_NOT_FOUND,
-    )
-    def delete_bundle_id(
-        self,
-        bundle_id_resource_id: ResourceId,
-        ignore_not_found: bool = False,
-    ) -> None:
-        """
-        Delete specified Bundle ID from Apple Developer portal
-        """
-
-        self._delete_resource(self.api_client.bundle_ids, bundle_id_resource_id, ignore_not_found)
-
-    @cli.action(
-        "create-certificate",
-        CertificateArgument.CERTIFICATE_TYPE,
-        CertificateArgument.PRIVATE_KEY,
-        CertificateArgument.PRIVATE_KEY_PASSWORD,
-        CertificateArgument.P12_CONTAINER_PASSWORD,
-        CertificateArgument.P12_CONTAINER_SAVE_PATH,
-        CommonArgument.SAVE,
-    )
-    def create_certificate(
-        self,
-        certificate_type: CertificateType = CertificateType.IOS_DEVELOPMENT,
-        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
-        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
-        p12_container_password: str = "",
-        p12_container_save_path: Optional[pathlib.Path] = None,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> SigningCertificate:
-        """
-        Create code signing certificates of given type
-        """
-
-        private_key = _get_certificate_key(certificate_key, certificate_key_password)
-        if private_key is None:
-            raise AppStoreConnectError("Cannot create resource without certificate private key")
-
-        csr = Certificate.create_certificate_signing_request(private_key)
-        csr_content = Certificate.get_certificate_signing_request_content(csr)
-
-        create_params = dict(csr_content=csr_content, certificate_type=certificate_type, omit_keys=["csr_content"])
-        certificate = self._create_resource(self.api_client.signing_certificates, should_print, **create_params)
-
-        if save:
-            self._save_certificate(certificate, private_key, p12_container_password, p12_container_save_path)
-        return certificate
-
-    @cli.action(
-        "get-certificate",
-        CertificateArgument.CERTIFICATE_RESOURCE_ID,
-        CertificateArgument.PRIVATE_KEY,
-        CertificateArgument.PRIVATE_KEY_PASSWORD,
-        CertificateArgument.P12_CONTAINER_PASSWORD,
-        CertificateArgument.P12_CONTAINER_SAVE_PATH,
-        CommonArgument.SAVE,
-    )
-    def get_certificate(
-        self,
-        certificate_resource_id: ResourceId,
-        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
-        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
-        p12_container_password: str = "",
-        p12_container_save_path: Optional[pathlib.Path] = None,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> SigningCertificate:
-        """
-        Get specified Signing Certificate from Apple Developer portal
-        """
-
-        private_key = _get_certificate_key(certificate_key, certificate_key_password)
-        if save and not private_key:
-            raise AppStoreConnectError("Cannot save resource without certificate private key")
-
-        certificate = self._get_resource(certificate_resource_id, self.api_client.signing_certificates, should_print)
-
-        if save:
-            self._save_certificate(
-                certificate,
-                cast(PrivateKey, private_key),
-                p12_container_password,
-                p12_container_save_path,
-            )
-        return certificate
-
-    @cli.action(
-        "delete-certificate",
-        CertificateArgument.CERTIFICATE_RESOURCE_ID,
-        CommonArgument.IGNORE_NOT_FOUND,
-    )
-    def delete_certificate(
-        self,
-        certificate_resource_id: ResourceId,
-        ignore_not_found: bool = False,
-    ) -> None:
-        """
-        Delete specified Signing Certificate from Apple Developer portal
-        """
-
-        self._delete_resource(self.api_client.signing_certificates, certificate_resource_id, ignore_not_found)
-
-    @cli.action(
-        "list-certificates",
-        CertificateArgument.CERTIFICATE_TYPES_OPTIONAL,
-        CertificateArgument.PROFILE_TYPE_OPTIONAL,
-        CertificateArgument.DISPLAY_NAME,
-        CertificateArgument.PRIVATE_KEY,
-        CertificateArgument.PRIVATE_KEY_PASSWORD,
-        CertificateArgument.P12_CONTAINER_PASSWORD,
-        CommonArgument.SAVE,
-    )
-    def list_certificates(
-        self,
-        certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None,
-        profile_type: Optional[ProfileType] = None,
-        display_name: Optional[str] = None,
-        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
-        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
-        p12_container_password: str = "",
-        save: bool = False,
-        should_print: bool = True,
-        **_deprecated_kwargs,
-    ) -> List[SigningCertificate]:
-        """
-        List Signing Certificates from Apple Developer Portal matching given constraints
-        """
-
-        private_key = _get_certificate_key(certificate_key, certificate_key_password)
-        if save and private_key is None:
-            raise AppStoreConnectError("Cannot create or save resource without certificate private key")
-
-        _certificate_type: Optional[CertificateType] = _deprecated_kwargs.get("certificate_type")
-        certificate_types_filter = self._resolve_certificate_types(_certificate_type, certificate_types, profile_type)
-
-        certificate_filter = self.api_client.signing_certificates.Filter(
-            certificate_type=certificate_types_filter,
-            display_name=display_name,
-        )
-        certificates = self._list_resources(
-            certificate_filter,
-            self.api_client.signing_certificates,
-            should_print,
-        )
-
-        if private_key:
-            certificates = [
-                certificate
-                for certificate in certificates
-                if Certificate.from_ans1(certificate.asn1_content).is_signed_with(private_key)
-            ]
-            self.printer.log_filtered(SigningCertificate, certificates, "for given private key")
-            for certificate in certificates:
-                self.logger.info(f"- {certificate.get_display_info()}")
-
-        if save:
-            self._save_certificates(
-                certificates,
-                cast(PrivateKey, private_key),
-                p12_container_password,
-            )
-
-        return certificates
-
-    def _resolve_certificate_types(
-        self,
-        certificate_type: Optional[CertificateType],
-        certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]],
-        profile_type: Optional[ProfileType],
-    ) -> Optional[List[CertificateType]]:
-        types: Set[CertificateType] = set()
-
-        if isinstance(certificate_types, CertificateType):
-            types.add(certificate_types)
-        elif certificate_types is not None:
-            types.update(certificate_types)
-
-        if isinstance(certificate_type, CertificateType):
-            warning = (
-                "Deprecation warning! Keyword argument "
-                '"certificate_type: Optional[CertificateType]" is deprecated in favor of '
-                '"certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None", '
-                "and is subject for removal."
-            )
-            self.logger.warning(Colors.RED(warning))
-            types.add(certificate_type)
-
-        if profile_type:
-            types.add(CertificateType.from_profile_type(profile_type))
-            # Include iOS and Mac App distribution certificate types backwards compatibility.
-            # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution
-            # certificates and consequently they too can be used with those profiles.
-            if profile_type is ProfileType.IOS_APP_STORE:
-                types.add(CertificateType.IOS_DISTRIBUTION)
-            elif profile_type is ProfileType.IOS_APP_ADHOC:
-                types.add(CertificateType.IOS_DISTRIBUTION)
-            elif profile_type is ProfileType.MAC_APP_STORE:
-                types.add(CertificateType.MAC_APP_DISTRIBUTION)
-            elif profile_type is ProfileType.MAC_APP_DIRECT:
-                types.add(CertificateType.DEVELOPER_ID_APPLICATION)
-
-        return list(types) if types else None
-
-    @cli.action(
-        "create-profile",
-        BundleIdArgument.BUNDLE_ID_RESOURCE_ID,
-        CertificateArgument.CERTIFICATE_RESOURCE_IDS,
-        ProfileArgument.DEVICE_RESOURCE_IDS,
-        ProfileArgument.PROFILE_TYPE,
-        ProfileArgument.PROFILE_NAME,
-        CommonArgument.SAVE,
-    )
-    def create_profile(
-        self,
-        bundle_id_resource_id: ResourceId,
-        certificate_resource_ids: Sequence[ResourceId],
-        device_resource_ids: Optional[Sequence[ResourceId]] = None,
-        profile_type: ProfileType = ProfileType.IOS_APP_DEVELOPMENT,
-        profile_name: Optional[str] = None,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> Profile:
-        """
-        Create provisioning profile of given type
-        """
-
-        bundle_id = self.get_bundle_id(bundle_id_resource_id, should_print=False)
-        if profile_name:
-            name = profile_name
-        elif profile_name is None:
-            name = f"{bundle_id.attributes.name} {profile_type.value.lower()} {int(time.time())}"
-        else:
-            raise AppStoreConnectError(f'"{profile_name}" is not a valid {Profile} name')
-
-        create_params = dict(
-            name=name,
-            profile_type=profile_type,
-            bundle_id=bundle_id_resource_id,
-            certificates=certificate_resource_ids,
-            devices=[],
-            omit_keys=["devices"],
-        )
-        if profile_type.devices_required():
-            create_params["devices"] = list(device_resource_ids) if device_resource_ids else []
-
-        try:
-            profile = self._create_resource(self.api_client.profiles, should_print, **create_params)
-        except ValueError as ve:
-            raise AppStoreConnectError(str(ve)) from ve
-
-        if save:
-            self._save_profile(profile)
-        return profile
-
-    @cli.action(
-        "get-profile",
-        ProfileArgument.PROFILE_RESOURCE_ID,
-        CommonArgument.SAVE,
-    )
-    def get_profile(
-        self,
-        profile_resource_id: ResourceId,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> Profile:
-        """
-        Get specified Profile from Apple Developer portal
-        """
-
-        profile = self._get_resource(profile_resource_id, self.api_client.profiles, should_print)
-        if save:
-            self._save_profile(profile)
-        return profile
-
-    @cli.action(
-        "delete-profile",
-        ProfileArgument.PROFILE_RESOURCE_ID,
-        CommonArgument.IGNORE_NOT_FOUND,
-    )
-    def delete_profile(
-        self,
-        profile_resource_id: ResourceId,
-        ignore_not_found: bool = False,
-    ) -> None:
-        """
-        Delete specified Profile from Apple Developer portal
-        """
-
-        self._delete_resource(self.api_client.profiles, profile_resource_id, ignore_not_found)
-
-    @cli.action(
-        "list-profiles",
-        ProfileArgument.PROFILE_TYPE_OPTIONAL,
-        ProfileArgument.PROFILE_STATE_OPTIONAL,
-        ProfileArgument.PROFILE_NAME,
-        CommonArgument.SAVE,
-    )
-    def list_profiles(
-        self,
-        profile_type: Optional[ProfileType] = None,
-        profile_state: Optional[ProfileState] = None,
-        profile_name: Optional[str] = None,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> List[Profile]:
-        """
-        List Profiles from Apple Developer portal matching given constraints
-        """
-        profile_filter = self.api_client.profiles.Filter(
-            profile_type=profile_type,
-            profile_state=profile_state,
-            name=profile_name,
-        )
-        profiles = self._list_resources(profile_filter, self.api_client.profiles, should_print)
-
-        if save:
-            self._save_profiles(profiles)
-        return profiles
-
-    @cli.action(
-        "list-bundle-id-profiles",
-        BundleIdArgument.BUNDLE_ID_RESOURCE_IDS,
-        ProfileArgument.PROFILE_TYPE_OPTIONAL,
-        ProfileArgument.PROFILE_STATE_OPTIONAL,
-        ProfileArgument.PROFILE_NAME,
-        CommonArgument.SAVE,
-    )
-    def list_bundle_id_profiles(
-        self,
-        bundle_id_resource_ids: Sequence[ResourceId],
-        profile_type: Optional[ProfileType] = None,
-        profile_state: Optional[ProfileState] = None,
-        profile_name: Optional[str] = None,
-        save: bool = False,
-        should_print: bool = True,
-    ) -> List[Profile]:
-        """
-        List provisioning profiles from Apple Developer Portal for specified Bundle IDs
-        """
-
-        profiles_filter = self.api_client.profiles.Filter(
-            profile_type=profile_type,
-            profile_state=profile_state,
-            name=profile_name,
-        )
-
-        profiles = []
-        for resource_id in set(bundle_id_resource_ids):
-            bundle_id_profiles = self._list_related_resources(
-                resource_id,
-                BundleId,
-                Profile,
-                self.api_client.bundle_ids.list_profiles,
-                profiles_filter,
-                should_print,
-            )
-            profiles.extend(bundle_id_profiles)
-
-        if save:
-            self._save_profiles(profiles)
-        return profiles
-
-    @cli.action(
-        "fetch-signing-files",
-        BundleIdArgument.BUNDLE_ID_IDENTIFIER,
-        BundleIdArgument.PLATFORM,
-        CertificateArgument.PRIVATE_KEY,
-        CertificateArgument.PRIVATE_KEY_PASSWORD,
-        CertificateArgument.P12_CONTAINER_PASSWORD,
-        ProfileArgument.PROFILE_TYPE,
-        BundleIdArgument.IDENTIFIER_STRICT_MATCH,
-        CommonArgument.CREATE_RESOURCE,
-    )
-    def fetch_signing_files(
-        self,
-        bundle_id_identifier: str,
-        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
-        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
-        p12_container_password: str = "",
-        platform: BundleIdPlatform = BundleIdPlatform.IOS,
-        profile_type: ProfileType = ProfileType.IOS_APP_DEVELOPMENT,
-        bundle_id_identifier_strict_match: bool = False,
-        create_resource: bool = False,
-    ) -> Tuple[List[Profile], List[SigningCertificate]]:
-        """
-        Fetch provisioning profiles and code signing certificates
-        for Bundle ID with given identifier
-        """
-
-        private_key = _get_certificate_key(certificate_key, certificate_key_password)
-        if private_key is None:
-            raise AppStoreConnectError(f"Cannot save {SigningCertificate.s} without certificate private key")
-
-        bundle_ids = self._get_or_create_bundle_ids(
-            bundle_id_identifier,
-            platform,
-            create_resource,
-            bundle_id_identifier_strict_match,
-        )
-        self.echo("")
-
-        certificates = self._get_or_create_certificates(
-            profile_type,
-            certificate_key,
-            certificate_key_password,
-            create_resource,
-        )
-        self.echo("")
-
-        profiles = self._get_or_create_profiles(
-            bundle_ids,
-            certificates,
-            profile_type,
-            create_resource,
-            platform,
-        )
-        self.echo("")
-
-        self._save_certificates(certificates, private_key, p12_container_password)
-        self._save_profiles(profiles)
-        return profiles, certificates
-
-    def _get_or_create_bundle_ids(
-        self,
-        bundle_id_identifier: str,
-        platform: BundleIdPlatform,
-        create_resource: bool,
-        strict_match: bool,
-    ) -> List[BundleId]:
-        bundle_ids = self.list_bundle_ids(
-            bundle_id_identifier,
-            platform=platform,
-            bundle_id_identifier_strict_match=strict_match,
-            should_print=False,
-        )
-        if not bundle_ids:
-            if not create_resource:
-                raise AppStoreConnectError(f"Did not find {BundleId.s} with identifier {bundle_id_identifier}")
-            bundle_ids.append(self.create_bundle_id(bundle_id_identifier, platform=platform, should_print=False))
-        else:
-            for bundle_id in bundle_ids:
-                self.logger.info(f"- {bundle_id.attributes.name} {bundle_id.attributes.identifier} ({bundle_id.id})")
-        return bundle_ids
-
-    def _get_or_create_certificates(
-        self,
-        profile_type: ProfileType,
-        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]],
-        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument],
-        create_resource: bool,
-    ) -> List[SigningCertificate]:
-        certificate_types = [CertificateType.from_profile_type(profile_type)]
-        # Include iOS and Mac App distribution certificate types backwards compatibility.
-        # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution
-        # certificates, and we want to keep using existing certificates for as long as possible.
-        if profile_type is ProfileType.IOS_APP_STORE:
-            certificate_types.append(CertificateType.IOS_DISTRIBUTION)
-        elif profile_type is ProfileType.IOS_APP_ADHOC:
-            certificate_types.append(CertificateType.IOS_DISTRIBUTION)
-        elif profile_type is ProfileType.MAC_APP_STORE:
-            certificate_types.append(CertificateType.MAC_APP_DISTRIBUTION)
-        elif profile_type is ProfileType.MAC_APP_DIRECT:
-            certificate_types.append(CertificateType.DEVELOPER_ID_APPLICATION)
-
-        certificates = self.list_certificates(
-            certificate_types=certificate_types,
-            certificate_key=certificate_key,
-            certificate_key_password=certificate_key_password,
-            should_print=False,
-        )
-
-        if not certificates:
-            if not create_resource:
-                _certificate_types = ", ".join(map(str, certificate_types))
-                raise AppStoreConnectError(f"Did not find {SigningCertificate.s} with type {_certificate_types}")
-            certificate = self.create_certificate(
-                certificate_types[0],
-                certificate_key=certificate_key,
-                certificate_key_password=certificate_key_password,
-                should_print=False,
-            )
-            certificates.append(certificate)
-        return certificates
-
-    def _create_missing_profiles(
-        self,
-        bundle_ids_without_profiles: Sequence[BundleId],
-        seed_certificates: Sequence[SigningCertificate],
-        profile_type: ProfileType,
-        platform: Optional[BundleIdPlatform] = None,
-    ) -> Iterator[Profile]:
-        if not bundle_ids_without_profiles:
-            return []
-        if platform is None:
-            platform = bundle_ids_without_profiles[0].attributes.platform
-
-        devices = self.list_devices(platform=platform, device_status=DeviceStatus.ENABLED, should_print=False)
-        certificates = self.list_certificates(profile_type=profile_type, should_print=False)
-        certificate_ids = list({c.id for c in chain(seed_certificates, certificates)})
-        device_ids = [d.id for d in devices if d.attributes.deviceClass.is_compatible(profile_type)]
-
-        for bundle_id in bundle_ids_without_profiles:
-            yield self.create_profile(
-                bundle_id.id,
-                certificate_ids,
-                device_ids,
-                profile_type=profile_type,
-                should_print=False,
-            )
-
-    def _get_or_create_profiles(
-        self,
-        bundle_ids: Sequence[BundleId],
-        certificates: Sequence[SigningCertificate],
-        profile_type: ProfileType,
-        create_resource: bool,
-        platform: Optional[BundleIdPlatform] = None,
-    ):
-        def has_certificate(profile) -> bool:
-            try:
-                profile_certificates = self.api_client.profiles.list_certificate_ids(profile)
-                return bool(certificate_ids.issubset({c.id for c in profile_certificates}))
-            except AppStoreConnectApiError as err:
-                error = f"Listing {SigningCertificate.s} for {Profile} {profile.id} failed unexpectedly"
-                self.logger.warning(Colors.YELLOW(f"{error}: {err.error_response}"))
-                return False
-
-        def missing_profile(bundle_id) -> bool:
-            try:
-                bundle_ids_profiles = self.api_client.bundle_ids.list_profile_ids(bundle_id)
-                return not (profile_ids & {p.id for p in bundle_ids_profiles})
-            except AppStoreConnectApiError as err:
-                error = f"Listing {Profile.s} for {BundleId} {bundle_id.id} failed unexpectedly"
-                self.logger.warning(Colors.YELLOW(f"{error}: {err.error_response}"))
-                return True
-
-        certificate_ids = {c.id for c in certificates}
-        profiles = self.list_bundle_id_profiles(
-            [bundle_id.id for bundle_id in bundle_ids],
-            profile_type=profile_type,
-            profile_state=ProfileState.ACTIVE,
-            should_print=False,
-        )
-        profiles = list(filter(has_certificate, profiles))
-
-        certificate_names = ", ".join(c.get_display_info() for c in certificates)
-        message = f"that contain {SigningCertificate.plural(len(certificates))} {certificate_names}"
-        self.printer.log_filtered(Profile, profiles, message)
-        for profile in profiles:
-            self.logger.info(f"- {profile.get_display_info()}")
-
-        profile_ids = {p.id for p in profiles}
-        bundle_ids_without_profiles = list(filter(missing_profile, bundle_ids))
-        if bundle_ids_without_profiles and not create_resource:
-            missing = ", ".join(f'"{bid.attributes.identifier}" [{bid.id}]' for bid in bundle_ids_without_profiles)
-            raise AppStoreConnectError(f"Did not find {profile_type} {Profile.s} for {BundleId.s}: {missing}")
-
-        created_profiles = self._create_missing_profiles(
-            bundle_ids_without_profiles,
-            certificates,
-            profile_type,
-            platform,
-        )
-        profiles.extend(created_profiles)
-        return profiles
-
-    @classmethod
-    def _get_unique_path(cls, file_name: str, destination: pathlib.Path) -> pathlib.Path:
-        if destination.exists() and not destination.is_dir():
-            raise ValueError(f"Destination {destination} is not a directory")
-        destination.mkdir(parents=True, exist_ok=True)
-        name = pathlib.Path(re.sub(r"[^\w.]", "_", file_name))
-        tf = tempfile.NamedTemporaryFile(
-            prefix=f"{name.stem}_",
-            suffix=name.suffix,
-            dir=destination,
-            delete=False,
-        )
-        tf.close()
-        return pathlib.Path(tf.name)
-
-    def _save_profile(self, profile: Profile) -> pathlib.Path:
-        profile_path = self._get_unique_path(
-            f"{profile.attributes.profileType}_{profile.id}{profile.profile_extension}",
-            self.profiles_directory,
-        )
-        profile_path.write_bytes(profile.profile_content)
-        self.printer.log_saved(profile, profile_path)
-        return profile_path
-
-    def _save_certificate(
-        self,
-        certificate: SigningCertificate,
-        private_key: PrivateKey,
-        p12_container_password: str,
-        certificate_save_path: Optional[pathlib.Path] = None,
-    ) -> pathlib.Path:
-        if certificate_save_path is None:
-            certificate_path = self._get_unique_path(
-                f"{certificate.attributes.certificateType}_{certificate.id}.p12",
-                self.certificates_directory,
-            )
-        else:
-            certificate_path = certificate_save_path
-        try:
-            p12_path = Certificate.from_ans1(certificate.asn1_content).export_p12(
-                private_key,
-                p12_container_password,
-                export_path=certificate_path,
-            )
-        except (ValueError, IOError) as error:
-            raise AppStoreConnectError(*error.args)
-        self.printer.log_saved(certificate, p12_path)
-        return p12_path
-
-    def _save_profiles(self, profiles: Sequence[Profile]) -> List[pathlib.Path]:
-        return [self._save_profile(profile) for profile in profiles]
-
-    def _save_certificates(
-        self,
-        certificates: Sequence[SigningCertificate],
-        private_key: PrivateKey,
-        p12_container_password: str,
-    ) -> List[pathlib.Path]:
-        return [self._save_certificate(c, private_key, p12_container_password) for c in certificates]
-
-
-if __name__ == "__main__":
-    AppStoreConnect.invoke_cli()
diff --git a/src/codemagic/tools/app_store_connect/__init__.py b/src/codemagic/tools/app_store_connect/__init__.py
new file mode 100644
index 00000000..7bd4b834
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/__init__.py
@@ -0,0 +1,1 @@
+from .app_store_connect import AppStoreConnect
diff --git a/src/codemagic/tools/app_store_connect/__main__.py b/src/codemagic/tools/app_store_connect/__main__.py
new file mode 100644
index 00000000..0af9fbf4
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/__main__.py
@@ -0,0 +1,4 @@
+if __name__ == "__main__":
+    from .app_store_connect import AppStoreConnect
+
+    AppStoreConnect.invoke_cli()
diff --git a/src/codemagic/tools/_app_store_connect/abstract_base_action.py b/src/codemagic/tools/app_store_connect/abstract_base_action.py
similarity index 71%
rename from src/codemagic/tools/_app_store_connect/abstract_base_action.py
rename to src/codemagic/tools/app_store_connect/abstract_base_action.py
index 58b2e792..efbcabc2 100644
--- a/src/codemagic/tools/_app_store_connect/abstract_base_action.py
+++ b/src/codemagic/tools/app_store_connect/abstract_base_action.py
@@ -24,20 +24,32 @@
 from codemagic.apple.resources import BetaReviewState
 from codemagic.apple.resources import Build
 from codemagic.apple.resources import BuildProcessingState
+from codemagic.apple.resources import BundleId
+from codemagic.apple.resources import BundleIdPlatform
+from codemagic.apple.resources import CertificateType
+from codemagic.apple.resources import Device
+from codemagic.apple.resources import DeviceStatus
 from codemagic.apple.resources import Locale
 from codemagic.apple.resources import Platform
+from codemagic.apple.resources import Profile
+from codemagic.apple.resources import ProfileState
+from codemagic.apple.resources import ProfileType
 from codemagic.apple.resources import ReleaseType
 from codemagic.apple.resources import ResourceId
 from codemagic.apple.resources import ReviewSubmission
 from codemagic.apple.resources import ReviewSubmissionItem
 from codemagic.apple.resources import ReviewSubmissionState
+from codemagic.apple.resources import SigningCertificate
 from codemagic.mixins import PathFinderMixin
+from codemagic.models import PrivateKey
 
 from .arguments import AppStoreVersionInfo
 from .arguments import AppStoreVersionLocalizationInfo
 from .arguments import BetaBuildInfo
+from .arguments import CertificateArgument
 from .arguments import Types
-from .resource_manager_mixin import ResourceManagerMixin
+from .mixins import ResourceManagerMixin
+from .mixins import SigningFileSaverMixin
 from .resource_printer import ResourcePrinter
 
 AppStoreVersionLocalizationInfos = Union[
@@ -46,7 +58,12 @@
 ]
 
 
-class AbstractBaseAction(ResourceManagerMixin, PathFinderMixin, metaclass=ABCMeta):
+class AbstractBaseAction(
+    ResourceManagerMixin,
+    SigningFileSaverMixin,
+    PathFinderMixin,
+    metaclass=ABCMeta,
+):
     logger: logging.Logger
     profiles_directory: pathlib.Path
     certificates_directory: pathlib.Path
@@ -55,6 +72,21 @@ class AbstractBaseAction(ResourceManagerMixin, PathFinderMixin, metaclass=ABCMet
     _issuer_id: Optional[IssuerId]
     _private_key: Optional[str]
 
+    @staticmethod
+    def _get_certificate_key(
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+    ) -> Optional[PrivateKey]:
+        if isinstance(certificate_key, PrivateKey):
+            return certificate_key
+        password = certificate_key_password.value if certificate_key_password else None
+        if certificate_key is not None:
+            try:
+                return PrivateKey.from_buffer(certificate_key.value, password)
+            except ValueError:
+                CertificateArgument.PRIVATE_KEY.raise_argument_error("Not a valid certificate private key")
+        return None
+
     # Define signatures for self-reference to other action groups
 
     @property
@@ -186,6 +218,51 @@ def create_beta_build_localization(
         _ = BetaBuildLocalizationsActionGroup.create_beta_build_localization  # Implementation
         raise NotImplementedError()
 
+    @abstractmethod
+    def create_bundle_id(
+        self,
+        bundle_id_identifier: str,
+        bundle_id_name: Optional[str] = None,
+        platform: BundleIdPlatform = BundleIdPlatform.IOS,
+        should_print: bool = True,
+    ) -> BundleId:
+        from .action_groups import BundleIdsActionGroup
+
+        _ = BundleIdsActionGroup.create_bundle_id  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def create_certificate(
+        self,
+        certificate_type: CertificateType = CertificateType.IOS_DEVELOPMENT,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        p12_container_save_path: Optional[pathlib.Path] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> SigningCertificate:
+        from .action_groups import CertificatesActionGroup
+
+        _ = CertificatesActionGroup.create_certificate  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def create_profile(
+        self,
+        bundle_id_resource_id: ResourceId,
+        certificate_resource_ids: Sequence[ResourceId],
+        device_resource_ids: Optional[Sequence[ResourceId]] = None,
+        profile_type: ProfileType = ProfileType.IOS_APP_DEVELOPMENT,
+        profile_name: Optional[str] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> Profile:
+        from .action_groups import ProfilesActionGroup
+
+        _ = ProfilesActionGroup.create_profile  # Implementation
+        raise NotImplementedError()
+
     @abstractmethod
     def create_review_submission(
         self,
@@ -260,6 +337,17 @@ def cancel_review_submissions(
         _ = AppsActionGroup.cancel_review_submissions  # Implementation
         raise NotImplementedError()
 
+    @abstractmethod
+    def get_bundle_id(
+        self,
+        bundle_id_resource_id: ResourceId,
+        should_print: bool = True,
+    ) -> BundleId:
+        from .action_groups import BundleIdsActionGroup
+
+        _ = BundleIdsActionGroup.get_bundle_id  # Implementation
+        raise NotImplementedError()
+
     @abstractmethod
     def list_app_store_version_localizations(
         self,
@@ -303,9 +391,69 @@ def list_builds(
         build_version_number: Optional[int] = None,
         should_print: bool = True,
     ) -> List[Build]:
-        from ..app_store_connect import AppStoreConnect
+        from .action_groups import BuildsActionGroup
+
+        _ = BuildsActionGroup.list_builds  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def list_bundle_ids(
+        self,
+        bundle_id_identifier: Optional[str] = None,
+        bundle_id_name: Optional[str] = None,
+        platform: Optional[BundleIdPlatform] = None,
+        bundle_id_identifier_strict_match: bool = False,
+        should_print: bool = True,
+    ) -> List[BundleId]:
+        from .action_groups import BundleIdsActionGroup
+
+        _ = BundleIdsActionGroup.list_bundle_ids  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def list_bundle_id_profiles(
+        self,
+        bundle_id_resource_ids: Sequence[ResourceId],
+        profile_type: Optional[ProfileType] = None,
+        profile_state: Optional[ProfileState] = None,
+        profile_name: Optional[str] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> List[Profile]:
+        from .action_groups import BundleIdsActionGroup
+
+        _ = BundleIdsActionGroup.list_bundle_id_profiles  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def list_certificates(
+        self,
+        certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None,
+        profile_type: Optional[ProfileType] = None,
+        display_name: Optional[str] = None,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        save: bool = False,
+        should_print: bool = True,
+        **_deprecated_kwargs,
+    ) -> List[SigningCertificate]:
+        from .action_groups import CertificatesActionGroup
+
+        _ = CertificatesActionGroup.list_certificates  # Implementation
+        raise NotImplementedError()
+
+    @abstractmethod
+    def list_devices(
+        self,
+        platform: Optional[BundleIdPlatform] = None,
+        device_name: Optional[str] = None,
+        device_status: Optional[DeviceStatus] = None,
+        should_print: bool = True,
+    ) -> List[Device]:
+        from .action_groups import DevicesActionGroup
 
-        _ = AppStoreConnect.list_builds  # Implementation
+        _ = DevicesActionGroup.list_devices  # Implementation
         raise NotImplementedError()
 
     @abstractmethod
diff --git a/src/codemagic/tools/_app_store_connect/action_group.py b/src/codemagic/tools/app_store_connect/action_group.py
similarity index 77%
rename from src/codemagic/tools/_app_store_connect/action_group.py
rename to src/codemagic/tools/app_store_connect/action_group.py
index aef92e5a..2c7f30f6 100644
--- a/src/codemagic/tools/_app_store_connect/action_group.py
+++ b/src/codemagic/tools/app_store_connect/action_group.py
@@ -26,6 +26,10 @@ class AppStoreConnectActionGroup(cli.ActionGroup):
         name="builds",
         description="Manage your builds in App Store Connect",
     )
+    BUNDLE_IDS = cli.ActionGroupProperties(
+        name="bundle-ids",
+        description="Manage bundle identifiers and their capabilities",
+    )
     BETA_BUILDS_LOCALIZATIONS = cli.ActionGroupProperties(
         name="beta-build-localizations",
         description="Manage your beta builds localizations in App Store Connect",
@@ -34,10 +38,22 @@ class AppStoreConnectActionGroup(cli.ActionGroup):
         name="beta-groups",
         description="Manage your groups of beta testers in App Store Connect",
     )
+    CERTIFICATES = cli.ActionGroupProperties(
+        name="certificates",
+        description="Manage code signing certificates",
+    )
     REVIEW_SUBMISSIONS = cli.ActionGroupProperties(
         name="review-submissions",
         description="Manage your App Store version review submissions",
     )
+    DEVICES = cli.ActionGroupProperties(
+        name="devices",
+        description="Manage Apple devices",
+    )
+    PROFILES = cli.ActionGroupProperties(
+        name="profiles",
+        description="Manage provisioning profiles",
+    )
     REVIEW_SUBMISSION_ITEMS = cli.ActionGroupProperties(
         name="review-submission-items",
         description="Manage the contents of your review submission",
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/__init__.py b/src/codemagic/tools/app_store_connect/action_groups/__init__.py
similarity index 76%
rename from src/codemagic/tools/_app_store_connect/action_groups/__init__.py
rename to src/codemagic/tools/app_store_connect/action_groups/__init__.py
index a40eb61a..57645e82 100644
--- a/src/codemagic/tools/_app_store_connect/action_groups/__init__.py
+++ b/src/codemagic/tools/app_store_connect/action_groups/__init__.py
@@ -6,5 +6,9 @@
 from .beta_build_localizations_action_group import BetaBuildLocalizationsActionGroup
 from .beta_groups_action_group import BetaGroupsActionGroup
 from .builds_action_group import BuildsActionGroup
+from .bundle_ids_action_group import BundleIdsActionGroup
+from .certificates_action_group import CertificatesActionGroup
+from .devices_action_group import DevicesActionGroup
+from .profiles_action_group import ProfilesActionGroup
 from .review_submission_items_actions_group import ReviewSubmissionItemsActionGroup
 from .review_submissions_actions_group import ReviewSubmissionsActionGroup
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/app_store_version_localizations_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/app_store_version_localizations_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/app_store_version_localizations_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/app_store_version_localizations_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/app_store_version_submissions_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/app_store_version_submissions_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/app_store_version_submissions_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/app_store_version_submissions_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/app_store_versions_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/app_store_versions_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/app_store_versions_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/app_store_versions_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/apps_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/apps_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/apps_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/apps_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/beta_app_review_submissions_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/beta_app_review_submissions_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/beta_app_review_submissions_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/beta_app_review_submissions_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/beta_build_localizations_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/beta_build_localizations_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/beta_build_localizations_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/beta_build_localizations_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/beta_groups_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/beta_groups_action_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/beta_groups_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/beta_groups_action_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/builds_action_group.py
similarity index 94%
rename from src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/builds_action_group.py
index e6ef4105..caa05c6a 100644
--- a/src/codemagic/tools/_app_store_connect/action_groups/builds_action_group.py
+++ b/src/codemagic/tools/app_store_connect/action_groups/builds_action_group.py
@@ -9,6 +9,7 @@
 from typing import Iterator
 from typing import List
 from typing import Optional
+from typing import Sequence
 from typing import Tuple
 from typing import Union
 
@@ -32,12 +33,15 @@
 from codemagic.apple.resources import ResourceId
 from codemagic.apple.resources import ReviewSubmission
 from codemagic.apple.resources import ReviewSubmissionItem
+from codemagic.apple.resources.enums import BetaReviewState
 from codemagic.apple.resources.enums import ReviewSubmissionState
+from codemagic.cli import Argument
 from codemagic.cli import Colors
 from codemagic.utilities import versions
 
 from ..abstract_base_action import AbstractBaseAction
 from ..action_group import AppStoreConnectActionGroup
+from ..arguments import AppArgument
 from ..arguments import AppStoreVersionArgument
 from ..arguments import AppStoreVersionInfo
 from ..arguments import AppStoreVersionLocalizationInfo
@@ -71,6 +75,45 @@ def get_build(self, build_id: ResourceId, should_print: bool = True) -> Build:
 
         return self._get_resource(build_id, self.api_client.builds, should_print)
 
+    @cli.action(
+        "list",
+        AppArgument.APPLICATION_ID_RESOURCE_ID_OPTIONAL,
+        *ArgumentGroups.LIST_BUILDS_FILTERING_ARGUMENTS,
+        action_group=AppStoreConnectActionGroup.BUILDS,
+        deprecation_info=cli.ActionDeprecationInfo("list-builds", "0.49.0"),
+    )
+    def list_builds(
+        self,
+        application_id: Optional[ResourceId] = None,
+        expired: Optional[bool] = None,
+        not_expired: Optional[bool] = None,
+        build_id: Optional[ResourceId] = None,
+        pre_release_version: Optional[str] = None,
+        processing_state: Optional[BuildProcessingState] = None,
+        beta_review_state: Optional[Union[BetaReviewState, Sequence[BetaReviewState]]] = None,
+        build_version_number: Optional[int] = None,
+        should_print: bool = True,
+    ) -> List[Build]:
+        """
+        List Builds from Apple Developer Portal matching given constraints
+        """
+        try:
+            expired_value = Argument.resolve_optional_two_way_switch(expired, not_expired)
+        except ValueError:
+            flags = f"{BuildArgument.EXPIRED.flag!r} and {BuildArgument.NOT_EXPIRED.flag!r}"
+            raise BuildArgument.NOT_EXPIRED.raise_argument_error(f"Using mutually exclusive switches {flags}.")
+
+        builds_filter = self.api_client.builds.Filter(
+            app=application_id,
+            expired=expired_value,
+            id=build_id,
+            processing_state=processing_state,
+            beta_app_review_submission_beta_review_state=beta_review_state,
+            version=build_version_number,
+            pre_release_version_version=pre_release_version,
+        )
+        return self._list_resources(builds_filter, self.api_client.builds, should_print)
+
     @cli.action(
         "expire",
         BuildArgument.BUILD_ID_RESOURCE_ID,
diff --git a/src/codemagic/tools/app_store_connect/action_groups/bundle_ids_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/bundle_ids_action_group.py
new file mode 100644
index 00000000..0cefd07b
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/action_groups/bundle_ids_action_group.py
@@ -0,0 +1,165 @@
+from __future__ import annotations
+
+from abc import ABCMeta
+from typing import List
+from typing import Optional
+from typing import Sequence
+
+from codemagic import cli
+from codemagic.apple.resources import BundleId
+from codemagic.apple.resources import BundleIdPlatform
+from codemagic.apple.resources import Profile
+from codemagic.apple.resources import ProfileState
+from codemagic.apple.resources import ProfileType
+from codemagic.apple.resources import ResourceId
+
+from ..abstract_base_action import AbstractBaseAction
+from ..action_group import AppStoreConnectActionGroup
+from ..arguments import BundleIdArgument
+from ..arguments import CommonArgument
+from ..arguments import ProfileArgument
+
+
+class BundleIdsActionGroup(AbstractBaseAction, metaclass=ABCMeta):
+    @cli.action(
+        "create",
+        BundleIdArgument.BUNDLE_ID_IDENTIFIER,
+        BundleIdArgument.BUNDLE_ID_NAME,
+        BundleIdArgument.PLATFORM,
+        action_group=AppStoreConnectActionGroup.BUNDLE_IDS,
+        deprecation_info=cli.ActionDeprecationInfo("create-bundle-id", "0.49.0"),
+    )
+    def create_bundle_id(
+        self,
+        bundle_id_identifier: str,
+        bundle_id_name: Optional[str] = None,
+        platform: BundleIdPlatform = BundleIdPlatform.IOS,
+        should_print: bool = True,
+    ) -> BundleId:
+        """
+        Create Bundle ID in Apple Developer portal for specifier identifier
+        """
+
+        if bundle_id_name is None:
+            bundle_id_name = bundle_id_identifier.replace(".", " ")
+
+        create_params = dict(identifier=bundle_id_identifier, name=bundle_id_name, platform=platform)
+        return self._create_resource(self.api_client.bundle_ids, should_print, **create_params)
+
+    @cli.action(
+        "get",
+        BundleIdArgument.BUNDLE_ID_RESOURCE_ID,
+        action_group=AppStoreConnectActionGroup.BUNDLE_IDS,
+        deprecation_info=cli.ActionDeprecationInfo("get-bundle-id", "0.49.0"),
+    )
+    def get_bundle_id(
+        self,
+        bundle_id_resource_id: ResourceId,
+        should_print: bool = True,
+    ) -> BundleId:
+        """
+        Get specified Bundle ID from Apple Developer portal
+        """
+
+        return self._get_resource(bundle_id_resource_id, self.api_client.bundle_ids, should_print)
+
+    @cli.action(
+        "list",
+        BundleIdArgument.BUNDLE_ID_IDENTIFIER_OPTIONAL,
+        BundleIdArgument.BUNDLE_ID_NAME,
+        BundleIdArgument.PLATFORM_OPTIONAL,
+        BundleIdArgument.IDENTIFIER_STRICT_MATCH,
+        action_group=AppStoreConnectActionGroup.BUNDLE_IDS,
+        deprecation_info=cli.ActionDeprecationInfo("list-bundle-ids", "0.49.0"),
+    )
+    def list_bundle_ids(
+        self,
+        bundle_id_identifier: Optional[str] = None,
+        bundle_id_name: Optional[str] = None,
+        platform: Optional[BundleIdPlatform] = None,
+        bundle_id_identifier_strict_match: bool = False,
+        should_print: bool = True,
+    ) -> List[BundleId]:
+        """
+        List Bundle IDs from Apple Developer portal matching given constraints
+        """
+
+        def predicate(bundle_id):
+            return bundle_id.attributes.identifier == bundle_id_identifier
+
+        bundle_id_filter = self.api_client.bundle_ids.Filter(
+            identifier=bundle_id_identifier,
+            name=bundle_id_name,
+            platform=platform,
+        )
+        bundle_ids = self._list_resources(
+            bundle_id_filter,
+            self.api_client.bundle_ids,
+            should_print,
+            filter_predicate=predicate if bundle_id_identifier_strict_match else None,
+        )
+
+        return bundle_ids
+
+    @cli.action(
+        "delete",
+        BundleIdArgument.BUNDLE_ID_RESOURCE_ID,
+        CommonArgument.IGNORE_NOT_FOUND,
+        action_group=AppStoreConnectActionGroup.BUNDLE_IDS,
+        deprecation_info=cli.ActionDeprecationInfo("delete-bundle-id", "0.49.0"),
+    )
+    def delete_bundle_id(
+        self,
+        bundle_id_resource_id: ResourceId,
+        ignore_not_found: bool = False,
+    ) -> None:
+        """
+        Delete specified Bundle ID from Apple Developer portal
+        """
+
+        self._delete_resource(self.api_client.bundle_ids, bundle_id_resource_id, ignore_not_found)
+
+    @cli.action(
+        "profiles",
+        BundleIdArgument.BUNDLE_ID_RESOURCE_IDS,
+        ProfileArgument.PROFILE_TYPE_OPTIONAL,
+        ProfileArgument.PROFILE_STATE_OPTIONAL,
+        ProfileArgument.PROFILE_NAME,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.BUNDLE_IDS,
+        deprecation_info=cli.ActionDeprecationInfo("list-bundle-id-profiles", "0.49.0"),
+    )
+    def list_bundle_id_profiles(
+        self,
+        bundle_id_resource_ids: Sequence[ResourceId],
+        profile_type: Optional[ProfileType] = None,
+        profile_state: Optional[ProfileState] = None,
+        profile_name: Optional[str] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> List[Profile]:
+        """
+        List provisioning profiles from Apple Developer Portal for specified Bundle IDs
+        """
+
+        profiles_filter = self.api_client.profiles.Filter(
+            profile_type=profile_type,
+            profile_state=profile_state,
+            name=profile_name,
+        )
+
+        profiles = []
+        for resource_id in set(bundle_id_resource_ids):
+            bundle_id_profiles = self._list_related_resources(
+                resource_id,
+                BundleId,
+                Profile,
+                self.api_client.bundle_ids.list_profiles,
+                profiles_filter,
+                should_print,
+            )
+            profiles.extend(bundle_id_profiles)
+
+        if save:
+            self._save_profiles(profiles)
+        return profiles
diff --git a/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py
new file mode 100644
index 00000000..67baa2fe
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/action_groups/certificates_action_group.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import pathlib
+from abc import ABCMeta
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Set
+from typing import Union
+from typing import cast
+
+from codemagic import cli
+from codemagic.apple.resources import CertificateType
+from codemagic.apple.resources import ProfileType
+from codemagic.apple.resources import ResourceId
+from codemagic.apple.resources import SigningCertificate
+from codemagic.cli import Colors
+from codemagic.models import Certificate
+from codemagic.models import PrivateKey
+
+from ..abstract_base_action import AbstractBaseAction
+from ..action_group import AppStoreConnectActionGroup
+from ..arguments import CertificateArgument
+from ..arguments import CommonArgument
+from ..arguments import Types
+from ..errors import AppStoreConnectError
+
+
+class CertificatesActionGroup(AbstractBaseAction, metaclass=ABCMeta):
+    @cli.action(
+        "create",
+        CertificateArgument.CERTIFICATE_TYPE,
+        CertificateArgument.PRIVATE_KEY,
+        CertificateArgument.PRIVATE_KEY_PASSWORD,
+        CertificateArgument.P12_CONTAINER_PASSWORD,
+        CertificateArgument.P12_CONTAINER_SAVE_PATH,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.CERTIFICATES,
+        deprecation_info=cli.ActionDeprecationInfo("create-certificate", "0.49.0"),
+    )
+    def create_certificate(
+        self,
+        certificate_type: CertificateType = CertificateType.IOS_DEVELOPMENT,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        p12_container_save_path: Optional[pathlib.Path] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> SigningCertificate:
+        """
+        Create code signing certificates of given type
+        """
+
+        private_key = self._get_certificate_key(certificate_key, certificate_key_password)
+        if private_key is None:
+            raise AppStoreConnectError("Cannot create resource without certificate private key")
+
+        csr = Certificate.create_certificate_signing_request(private_key)
+        csr_content = Certificate.get_certificate_signing_request_content(csr)
+
+        create_params = dict(csr_content=csr_content, certificate_type=certificate_type, omit_keys=["csr_content"])
+        certificate = self._create_resource(self.api_client.signing_certificates, should_print, **create_params)
+
+        if save:
+            self._save_certificate(certificate, private_key, p12_container_password, p12_container_save_path)
+        return certificate
+
+    @cli.action(
+        "get",
+        CertificateArgument.CERTIFICATE_RESOURCE_ID,
+        CertificateArgument.PRIVATE_KEY,
+        CertificateArgument.PRIVATE_KEY_PASSWORD,
+        CertificateArgument.P12_CONTAINER_PASSWORD,
+        CertificateArgument.P12_CONTAINER_SAVE_PATH,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.CERTIFICATES,
+        deprecation_info=cli.ActionDeprecationInfo("get-certificate", "0.49.0"),
+    )
+    def get_certificate(
+        self,
+        certificate_resource_id: ResourceId,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        p12_container_save_path: Optional[pathlib.Path] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> SigningCertificate:
+        """
+        Get specified Signing Certificate from Apple Developer portal
+        """
+
+        private_key = self._get_certificate_key(certificate_key, certificate_key_password)
+        if save and not private_key:
+            raise AppStoreConnectError("Cannot save resource without certificate private key")
+
+        certificate = self._get_resource(certificate_resource_id, self.api_client.signing_certificates, should_print)
+
+        if save:
+            self._save_certificate(
+                certificate,
+                cast(PrivateKey, private_key),
+                p12_container_password,
+                p12_container_save_path,
+            )
+        return certificate
+
+    @cli.action(
+        "delete",
+        CertificateArgument.CERTIFICATE_RESOURCE_ID,
+        CommonArgument.IGNORE_NOT_FOUND,
+        action_group=AppStoreConnectActionGroup.CERTIFICATES,
+        deprecation_info=cli.ActionDeprecationInfo("delete-certificate", "0.49.0"),
+    )
+    def delete_certificate(
+        self,
+        certificate_resource_id: ResourceId,
+        ignore_not_found: bool = False,
+    ) -> None:
+        """
+        Delete specified Signing Certificate from Apple Developer portal
+        """
+
+        self._delete_resource(self.api_client.signing_certificates, certificate_resource_id, ignore_not_found)
+
+    @cli.action(
+        "list",
+        CertificateArgument.CERTIFICATE_TYPES_OPTIONAL,
+        CertificateArgument.PROFILE_TYPE_OPTIONAL,
+        CertificateArgument.DISPLAY_NAME,
+        CertificateArgument.PRIVATE_KEY,
+        CertificateArgument.PRIVATE_KEY_PASSWORD,
+        CertificateArgument.P12_CONTAINER_PASSWORD,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.CERTIFICATES,
+        deprecation_info=cli.ActionDeprecationInfo("list-certificates", "0.49.0"),
+    )
+    def list_certificates(
+        self,
+        certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None,
+        profile_type: Optional[ProfileType] = None,
+        display_name: Optional[str] = None,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        save: bool = False,
+        should_print: bool = True,
+        **_deprecated_kwargs,
+    ) -> List[SigningCertificate]:
+        """
+        List Signing Certificates from Apple Developer Portal matching given constraints
+        """
+
+        private_key = self._get_certificate_key(certificate_key, certificate_key_password)
+        if save and private_key is None:
+            raise AppStoreConnectError("Cannot create or save resource without certificate private key")
+
+        _certificate_type: Optional[CertificateType] = _deprecated_kwargs.get("certificate_type")
+        certificate_types_filter = self._resolve_certificate_types(_certificate_type, certificate_types, profile_type)
+
+        certificate_filter = self.api_client.signing_certificates.Filter(
+            certificate_type=certificate_types_filter,
+            display_name=display_name,
+        )
+        certificates = self._list_resources(
+            certificate_filter,
+            self.api_client.signing_certificates,
+            should_print,
+        )
+
+        if private_key:
+            certificates = [
+                certificate
+                for certificate in certificates
+                if Certificate.from_ans1(certificate.asn1_content).is_signed_with(private_key)
+            ]
+            self.printer.log_filtered(SigningCertificate, certificates, "for given private key")
+            for certificate in certificates:
+                self.logger.info(f"- {certificate.get_display_info()}")
+
+        if save:
+            self._save_certificates(
+                certificates,
+                cast(PrivateKey, private_key),
+                p12_container_password,
+            )
+
+        return certificates
+
+    def _resolve_certificate_types(
+        self,
+        certificate_type: Optional[CertificateType],
+        certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]],
+        profile_type: Optional[ProfileType],
+    ) -> Optional[List[CertificateType]]:
+        types: Set[CertificateType] = set()
+
+        if isinstance(certificate_types, CertificateType):
+            types.add(certificate_types)
+        elif certificate_types is not None:
+            types.update(certificate_types)
+
+        if isinstance(certificate_type, CertificateType):
+            warning = (
+                "Deprecation warning! Keyword argument "
+                '"certificate_type: Optional[CertificateType]" is deprecated in favor of '
+                '"certificate_types: Optional[Union[CertificateType, Sequence[CertificateType]]] = None", '
+                "and is subject for removal."
+            )
+            self.logger.warning(Colors.RED(warning))
+            types.add(certificate_type)
+
+        if profile_type:
+            types.add(CertificateType.from_profile_type(profile_type))
+            # Include iOS and Mac App distribution certificate types backwards compatibility.
+            # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution
+            # certificates, and consequently they too can be used with those profiles.
+            if profile_type is ProfileType.IOS_APP_STORE:
+                types.add(CertificateType.IOS_DISTRIBUTION)
+            elif profile_type is ProfileType.IOS_APP_ADHOC:
+                types.add(CertificateType.IOS_DISTRIBUTION)
+            elif profile_type is ProfileType.MAC_APP_STORE:
+                types.add(CertificateType.MAC_APP_DISTRIBUTION)
+            elif profile_type is ProfileType.MAC_APP_DIRECT:
+                types.add(CertificateType.DEVELOPER_ID_APPLICATION)
+
+        return list(types) if types else None
diff --git a/src/codemagic/tools/app_store_connect/action_groups/devices_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/devices_action_group.py
new file mode 100644
index 00000000..3c0fed2e
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/action_groups/devices_action_group.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+from abc import ABCMeta
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Union
+
+from codemagic import cli
+from codemagic.apple.resources import BundleIdPlatform
+from codemagic.apple.resources import Device
+from codemagic.apple.resources import DeviceStatus
+from codemagic.cli import Colors
+
+from ..abstract_base_action import AbstractBaseAction
+from ..action_group import AppStoreConnectActionGroup
+from ..arguments import BundleIdArgument
+from ..arguments import DeviceArgument
+from ..arguments import Types
+from ..errors import AppStoreConnectError
+
+
+class DevicesActionGroup(AbstractBaseAction, metaclass=ABCMeta):
+    @cli.action(
+        "list",
+        BundleIdArgument.PLATFORM_OPTIONAL,
+        DeviceArgument.DEVICE_NAME_OPTIONAL,
+        DeviceArgument.DEVICE_STATUS,
+        action_group=AppStoreConnectActionGroup.DEVICES,
+        deprecation_info=cli.ActionDeprecationInfo("list-devices", "0.49.0"),
+    )
+    def list_devices(
+        self,
+        platform: Optional[BundleIdPlatform] = None,
+        device_name: Optional[str] = None,
+        device_status: Optional[DeviceStatus] = None,
+        should_print: bool = True,
+    ) -> List[Device]:
+        """
+        List Devices from Apple Developer portal matching given constraints
+        """
+
+        device_filter = self.api_client.devices.Filter(
+            name=device_name,
+            platform=platform,
+            status=device_status,
+        )
+        return self._list_resources(device_filter, self.api_client.devices, should_print)
+
+    @cli.action(
+        "register",
+        BundleIdArgument.PLATFORM,
+        DeviceArgument.DEVICE_NAME,
+        DeviceArgument.DEVICE_UDIDS,
+        DeviceArgument.IGNORE_REGISTRATION_ERRORS,
+        action_group=AppStoreConnectActionGroup.DEVICES,
+        deprecation_info=cli.ActionDeprecationInfo("register-device", "0.49.0"),
+    )
+    def register_device(
+        self,
+        platform: BundleIdPlatform,
+        device_name: str,
+        device_udid: Optional[str] = None,  # Deprecated in https://github.com/codemagic-ci-cd/cli-tools/pull/331
+        device_udids: Optional[
+            Union[
+                str,
+                Types.DeviceUdidsArgument,
+                Sequence[Union[str, Types.DeviceUdidsArgument]],
+            ]
+        ] = None,
+        ignore_registration_errors: bool = DeviceArgument.IGNORE_REGISTRATION_ERRORS.get_default(),
+        should_print: bool = True,
+    ) -> List[Device]:
+        """
+        Register new Devices for app development
+        """
+        if not device_udids and not device_udid:
+            DeviceArgument.DEVICE_UDIDS.raise_argument_error("At least one device UDID is required")
+
+        if device_udids is None:
+            device_udids = []
+        if isinstance(device_udids, str) or isinstance(device_udids, Types.DeviceUdidsArgument):
+            device_udids = [device_udids]
+
+        if device_udid:
+            device_udids = [device_udid, *device_udids]
+
+        device_udids_values: List[str] = []
+        for device_udids in device_udids:
+            if isinstance(device_udids, Types.DeviceUdidsArgument):
+                device_udids_values.extend(device_udids.value)
+            else:
+                device_udids_values.append(device_udids)
+
+        registered_devices = []
+        for device_udid in device_udids_values:
+            try:
+                device = self._create_resource(
+                    self.api_client.devices,
+                    should_print,
+                    udid=device_udid,
+                    name=device_name,
+                    platform=platform,
+                )
+            except AppStoreConnectError as error:
+                if not ignore_registration_errors:
+                    raise error
+                self.logger.error(Colors.YELLOW(f"Failed to register a device: {error.args[0]}"))
+            else:
+                registered_devices.append(device)
+
+            self.echo("") if should_print else None
+
+        return registered_devices
diff --git a/src/codemagic/tools/app_store_connect/action_groups/profiles_action_group.py b/src/codemagic/tools/app_store_connect/action_groups/profiles_action_group.py
new file mode 100644
index 00000000..351028f0
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/action_groups/profiles_action_group.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import time
+from abc import ABCMeta
+from typing import List
+from typing import Optional
+from typing import Sequence
+
+from codemagic import cli
+from codemagic.apple.resources import Profile
+from codemagic.apple.resources import ProfileState
+from codemagic.apple.resources import ProfileType
+from codemagic.apple.resources import ResourceId
+
+from ..abstract_base_action import AbstractBaseAction
+from ..action_group import AppStoreConnectActionGroup
+from ..arguments import BundleIdArgument
+from ..arguments import CertificateArgument
+from ..arguments import CommonArgument
+from ..arguments import ProfileArgument
+from ..errors import AppStoreConnectError
+
+
+class ProfilesActionGroup(AbstractBaseAction, metaclass=ABCMeta):
+    @cli.action(
+        "create",
+        BundleIdArgument.BUNDLE_ID_RESOURCE_ID,
+        CertificateArgument.CERTIFICATE_RESOURCE_IDS,
+        ProfileArgument.DEVICE_RESOURCE_IDS,
+        ProfileArgument.PROFILE_TYPE,
+        ProfileArgument.PROFILE_NAME,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.PROFILES,
+        deprecation_info=cli.ActionDeprecationInfo("create-profile", "0.49.0"),
+    )
+    def create_profile(
+        self,
+        bundle_id_resource_id: ResourceId,
+        certificate_resource_ids: Sequence[ResourceId],
+        device_resource_ids: Optional[Sequence[ResourceId]] = None,
+        profile_type: ProfileType = ProfileType.IOS_APP_DEVELOPMENT,
+        profile_name: Optional[str] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> Profile:
+        """
+        Create provisioning profile of given type
+        """
+
+        bundle_id = self.get_bundle_id(bundle_id_resource_id, should_print=False)
+        if profile_name:
+            name = profile_name
+        elif profile_name is None:
+            name = f"{bundle_id.attributes.name} {profile_type.value.lower()} {int(time.time())}"
+        else:
+            raise AppStoreConnectError(f'"{profile_name}" is not a valid {Profile} name')
+
+        create_params = dict(
+            name=name,
+            profile_type=profile_type,
+            bundle_id=bundle_id_resource_id,
+            certificates=certificate_resource_ids,
+            devices=[],
+            omit_keys=["devices"],
+        )
+        if profile_type.devices_required():
+            create_params["devices"] = list(device_resource_ids) if device_resource_ids else []
+
+        try:
+            profile = self._create_resource(self.api_client.profiles, should_print, **create_params)
+        except ValueError as ve:
+            raise AppStoreConnectError(str(ve)) from ve
+
+        if save:
+            self._save_profile(profile)
+        return profile
+
+    @cli.action(
+        "get",
+        ProfileArgument.PROFILE_RESOURCE_ID,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.PROFILES,
+        deprecation_info=cli.ActionDeprecationInfo("get-profile", "0.49.0"),
+    )
+    def get_profile(
+        self,
+        profile_resource_id: ResourceId,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> Profile:
+        """
+        Get specified Profile from Apple Developer portal
+        """
+
+        profile = self._get_resource(profile_resource_id, self.api_client.profiles, should_print)
+        if save:
+            self._save_profile(profile)
+        return profile
+
+    @cli.action(
+        "delete",
+        ProfileArgument.PROFILE_RESOURCE_ID,
+        CommonArgument.IGNORE_NOT_FOUND,
+        action_group=AppStoreConnectActionGroup.PROFILES,
+        deprecation_info=cli.ActionDeprecationInfo("delete-profile", "0.49.0"),
+    )
+    def delete_profile(
+        self,
+        profile_resource_id: ResourceId,
+        ignore_not_found: bool = False,
+    ) -> None:
+        """
+        Delete specified Profile from Apple Developer portal
+        """
+
+        self._delete_resource(self.api_client.profiles, profile_resource_id, ignore_not_found)
+
+    @cli.action(
+        "list",
+        ProfileArgument.PROFILE_TYPE_OPTIONAL,
+        ProfileArgument.PROFILE_STATE_OPTIONAL,
+        ProfileArgument.PROFILE_NAME,
+        CommonArgument.SAVE,
+        action_group=AppStoreConnectActionGroup.PROFILES,
+        deprecation_info=cli.ActionDeprecationInfo("list-profiles", "0.49.0"),
+    )
+    def list_profiles(
+        self,
+        profile_type: Optional[ProfileType] = None,
+        profile_state: Optional[ProfileState] = None,
+        profile_name: Optional[str] = None,
+        save: bool = False,
+        should_print: bool = True,
+    ) -> List[Profile]:
+        """
+        List Profiles from Apple Developer portal matching given constraints
+        """
+        profile_filter = self.api_client.profiles.Filter(
+            profile_type=profile_type,
+            profile_state=profile_state,
+            name=profile_name,
+        )
+        profiles = self._list_resources(profile_filter, self.api_client.profiles, should_print)
+
+        if save:
+            self._save_profiles(profiles)
+        return profiles
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/review_submission_items_actions_group.py b/src/codemagic/tools/app_store_connect/action_groups/review_submission_items_actions_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/review_submission_items_actions_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/review_submission_items_actions_group.py
diff --git a/src/codemagic/tools/_app_store_connect/action_groups/review_submissions_actions_group.py b/src/codemagic/tools/app_store_connect/action_groups/review_submissions_actions_group.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/action_groups/review_submissions_actions_group.py
rename to src/codemagic/tools/app_store_connect/action_groups/review_submissions_actions_group.py
diff --git a/src/codemagic/tools/_app_store_connect/actions/__init__.py b/src/codemagic/tools/app_store_connect/actions/__init__.py
similarity index 80%
rename from src/codemagic/tools/_app_store_connect/actions/__init__.py
rename to src/codemagic/tools/app_store_connect/actions/__init__.py
index 2406c1d8..0674c9ab 100644
--- a/src/codemagic/tools/_app_store_connect/actions/__init__.py
+++ b/src/codemagic/tools/app_store_connect/actions/__init__.py
@@ -1,3 +1,4 @@
+from .fetch_signing_files_action import FetchSigningFilesAction
 from .latest_build_number_actions import GetLatestAppStoreBuildNumberAction
 from .latest_build_number_actions import GetLatestBuildNumberAction
 from .latest_build_number_actions import GetLatestTestflightBuildNumberAction
diff --git a/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py b/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py
new file mode 100644
index 00000000..5c04206b
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/actions/fetch_signing_files_action.py
@@ -0,0 +1,237 @@
+from __future__ import annotations
+
+from abc import ABCMeta
+from itertools import chain
+from typing import Iterator
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Tuple
+from typing import Union
+
+from codemagic import cli
+from codemagic.apple import AppStoreConnectApiError
+from codemagic.apple.resources import BundleId
+from codemagic.apple.resources import BundleIdPlatform
+from codemagic.apple.resources import CertificateType
+from codemagic.apple.resources import DeviceStatus
+from codemagic.apple.resources import Profile
+from codemagic.apple.resources import ProfileState
+from codemagic.apple.resources import ProfileType
+from codemagic.apple.resources import SigningCertificate
+from codemagic.cli import Colors
+from codemagic.models import PrivateKey
+
+from ..abstract_base_action import AbstractBaseAction
+from ..arguments import BundleIdArgument
+from ..arguments import CertificateArgument
+from ..arguments import CommonArgument
+from ..arguments import ProfileArgument
+from ..arguments import Types
+from ..errors import AppStoreConnectError
+
+
+class FetchSigningFilesAction(AbstractBaseAction, metaclass=ABCMeta):
+    @cli.action(
+        "fetch-signing-files",
+        BundleIdArgument.BUNDLE_ID_IDENTIFIER,
+        BundleIdArgument.PLATFORM,
+        CertificateArgument.PRIVATE_KEY,
+        CertificateArgument.PRIVATE_KEY_PASSWORD,
+        CertificateArgument.P12_CONTAINER_PASSWORD,
+        ProfileArgument.PROFILE_TYPE,
+        BundleIdArgument.IDENTIFIER_STRICT_MATCH,
+        CommonArgument.CREATE_RESOURCE,
+    )
+    def fetch_signing_files(
+        self,
+        bundle_id_identifier: str,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]] = None,
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument] = None,
+        p12_container_password: str = "",
+        platform: BundleIdPlatform = BundleIdPlatform.IOS,
+        profile_type: ProfileType = ProfileType.IOS_APP_DEVELOPMENT,
+        bundle_id_identifier_strict_match: bool = False,
+        create_resource: bool = False,
+    ) -> Tuple[List[Profile], List[SigningCertificate]]:
+        """
+        Fetch provisioning profiles and code signing certificates
+        for Bundle ID with given identifier
+        """
+
+        private_key = self._get_certificate_key(certificate_key, certificate_key_password)
+        if private_key is None:
+            raise AppStoreConnectError(f"Cannot save {SigningCertificate.s} without certificate private key")
+
+        bundle_ids = self._get_or_create_bundle_ids(
+            bundle_id_identifier,
+            platform,
+            create_resource,
+            bundle_id_identifier_strict_match,
+        )
+        self.echo("")
+
+        certificates = self._get_or_create_certificates(
+            profile_type,
+            certificate_key,
+            certificate_key_password,
+            create_resource,
+        )
+        self.echo("")
+
+        profiles = self._get_or_create_profiles(
+            bundle_ids,
+            certificates,
+            profile_type,
+            create_resource,
+            platform,
+        )
+        self.echo("")
+
+        self._save_certificates(certificates, private_key, p12_container_password)
+        self._save_profiles(profiles)
+        return profiles, certificates
+
+    def _get_or_create_bundle_ids(
+        self,
+        bundle_id_identifier: str,
+        platform: BundleIdPlatform,
+        create_resource: bool,
+        strict_match: bool,
+    ) -> List[BundleId]:
+        bundle_ids = self.list_bundle_ids(
+            bundle_id_identifier,
+            platform=platform,
+            bundle_id_identifier_strict_match=strict_match,
+            should_print=False,
+        )
+        if not bundle_ids:
+            if not create_resource:
+                raise AppStoreConnectError(f"Did not find {BundleId.s} with identifier {bundle_id_identifier}")
+            bundle_ids.append(self.create_bundle_id(bundle_id_identifier, platform=platform, should_print=False))
+        else:
+            for bundle_id in bundle_ids:
+                self.logger.info(f"- {bundle_id.attributes.name} {bundle_id.attributes.identifier} ({bundle_id.id})")
+        return bundle_ids
+
+    def _get_or_create_certificates(
+        self,
+        profile_type: ProfileType,
+        certificate_key: Optional[Union[PrivateKey, Types.CertificateKeyArgument]],
+        certificate_key_password: Optional[Types.CertificateKeyPasswordArgument],
+        create_resource: bool,
+    ) -> List[SigningCertificate]:
+        certificate_types = [CertificateType.from_profile_type(profile_type)]
+        # Include iOS and Mac App distribution certificate types backwards compatibility.
+        # In the past iOS and Mac App Store profiles used to map to iOS and Mac App distribution
+        # certificates, and we want to keep using existing certificates for as long as possible.
+        if profile_type is ProfileType.IOS_APP_STORE:
+            certificate_types.append(CertificateType.IOS_DISTRIBUTION)
+        elif profile_type is ProfileType.IOS_APP_ADHOC:
+            certificate_types.append(CertificateType.IOS_DISTRIBUTION)
+        elif profile_type is ProfileType.MAC_APP_STORE:
+            certificate_types.append(CertificateType.MAC_APP_DISTRIBUTION)
+        elif profile_type is ProfileType.MAC_APP_DIRECT:
+            certificate_types.append(CertificateType.DEVELOPER_ID_APPLICATION)
+
+        certificates = self.list_certificates(
+            certificate_types=certificate_types,
+            certificate_key=certificate_key,
+            certificate_key_password=certificate_key_password,
+            should_print=False,
+        )
+
+        if not certificates:
+            if not create_resource:
+                _certificate_types = ", ".join(map(str, certificate_types))
+                raise AppStoreConnectError(f"Did not find {SigningCertificate.s} with type {_certificate_types}")
+            certificate = self.create_certificate(
+                certificate_types[0],
+                certificate_key=certificate_key,
+                certificate_key_password=certificate_key_password,
+                should_print=False,
+            )
+            certificates.append(certificate)
+        return certificates
+
+    def _get_or_create_profiles(
+        self,
+        bundle_ids: Sequence[BundleId],
+        certificates: Sequence[SigningCertificate],
+        profile_type: ProfileType,
+        create_resource: bool,
+        platform: Optional[BundleIdPlatform] = None,
+    ):
+        def has_certificate(profile) -> bool:
+            try:
+                profile_certificates = self.api_client.profiles.list_certificate_ids(profile)
+                return bool(certificate_ids.issubset({c.id for c in profile_certificates}))
+            except AppStoreConnectApiError as err:
+                error = f"Listing {SigningCertificate.s} for {Profile} {profile.id} failed unexpectedly"
+                self.logger.warning(Colors.YELLOW(f"{error}: {err.error_response}"))
+                return False
+
+        def missing_profile(bundle_id) -> bool:
+            try:
+                bundle_ids_profiles = self.api_client.bundle_ids.list_profile_ids(bundle_id)
+                return not (profile_ids & {p.id for p in bundle_ids_profiles})
+            except AppStoreConnectApiError as err:
+                error = f"Listing {Profile.s} for {BundleId} {bundle_id.id} failed unexpectedly"
+                self.logger.warning(Colors.YELLOW(f"{error}: {err.error_response}"))
+                return True
+
+        certificate_ids = {c.id for c in certificates}
+        profiles = self.list_bundle_id_profiles(
+            [bundle_id.id for bundle_id in bundle_ids],
+            profile_type=profile_type,
+            profile_state=ProfileState.ACTIVE,
+            should_print=False,
+        )
+        profiles = list(filter(has_certificate, profiles))
+
+        certificate_names = ", ".join(c.get_display_info() for c in certificates)
+        message = f"that contain {SigningCertificate.plural(len(certificates))} {certificate_names}"
+        self.printer.log_filtered(Profile, profiles, message)
+        for profile in profiles:
+            self.logger.info(f"- {profile.get_display_info()}")
+
+        profile_ids = {p.id for p in profiles}
+        bundle_ids_without_profiles = list(filter(missing_profile, bundle_ids))
+        if bundle_ids_without_profiles and not create_resource:
+            missing = ", ".join(f'"{bid.attributes.identifier}" [{bid.id}]' for bid in bundle_ids_without_profiles)
+            raise AppStoreConnectError(f"Did not find {profile_type} {Profile.s} for {BundleId.s}: {missing}")
+
+        created_profiles = self._create_missing_profiles(
+            bundle_ids_without_profiles,
+            certificates,
+            profile_type,
+            platform,
+        )
+        profiles.extend(created_profiles)
+        return profiles
+
+    def _create_missing_profiles(
+        self,
+        bundle_ids_without_profiles: Sequence[BundleId],
+        seed_certificates: Sequence[SigningCertificate],
+        profile_type: ProfileType,
+        platform: Optional[BundleIdPlatform] = None,
+    ) -> Iterator[Profile]:
+        if not bundle_ids_without_profiles:
+            return []
+        if platform is None:
+            platform = bundle_ids_without_profiles[0].attributes.platform
+
+        devices = self.list_devices(platform=platform, device_status=DeviceStatus.ENABLED, should_print=False)
+        certificates = self.list_certificates(profile_type=profile_type, should_print=False)
+        certificate_ids = list({c.id for c in chain(seed_certificates, certificates)})
+        device_ids = [d.id for d in devices if d.attributes.deviceClass.is_compatible(profile_type)]
+
+        for bundle_id in bundle_ids_without_profiles:
+            yield self.create_profile(
+                bundle_id.id,
+                certificate_ids,
+                device_ids,
+                profile_type=profile_type,
+                should_print=False,
+            )
diff --git a/src/codemagic/tools/_app_store_connect/actions/publish_action.py b/src/codemagic/tools/app_store_connect/actions/publish_action.py
similarity index 99%
rename from src/codemagic/tools/_app_store_connect/actions/publish_action.py
rename to src/codemagic/tools/app_store_connect/actions/publish_action.py
index 4bc95706..2d288f6f 100644
--- a/src/codemagic/tools/_app_store_connect/actions/publish_action.py
+++ b/src/codemagic/tools/app_store_connect/actions/publish_action.py
@@ -24,9 +24,9 @@
 from codemagic.models import Altool
 from codemagic.models.application_package import Ipa
 from codemagic.models.application_package import MacOsPackage
-from codemagic.tools._app_store_connect.arguments import AppStoreVersionArgument
 
 from ..abstract_base_action import AbstractBaseAction
+from ..arguments import AppStoreVersionArgument
 from ..arguments import AppStoreVersionInfo
 from ..arguments import AppStoreVersionLocalizationArgument
 from ..arguments import AppStoreVersionLocalizationInfo
diff --git a/src/codemagic/tools/app_store_connect/app_store_connect.py b/src/codemagic/tools/app_store_connect/app_store_connect.py
new file mode 100755
index 00000000..fe1ab09f
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/app_store_connect.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+
+from __future__ import annotations
+
+import argparse
+import pathlib
+from functools import lru_cache
+from typing import Optional
+
+import jwt
+
+from codemagic import cli
+from codemagic.apple.app_store_connect import AppStoreConnectApiClient
+from codemagic.apple.app_store_connect import IssuerId
+from codemagic.apple.app_store_connect import KeyIdentifier
+from codemagic.mixins import PathFinderMixin
+from codemagic.models import Certificate
+from codemagic.models import ProvisioningProfile
+from codemagic.utilities import log
+
+from . import action_groups
+from . import actions
+from . import mixins
+from .arguments import AppStoreConnectArgument
+from .arguments import Types
+from .resource_printer import ResourcePrinter
+
+
+@cli.common_arguments(*AppStoreConnectArgument)
+class AppStoreConnect(
+    cli.CliApp,
+    action_groups.AppStoreVersionLocalizationsActionGroup,
+    action_groups.AppStoreVersionSubmissionsActionGroup,
+    action_groups.AppStoreVersionsActionGroup,
+    action_groups.AppsActionGroup,
+    action_groups.BetaAppReviewSubmissionsActionGroup,
+    action_groups.BetaBuildLocalizationsActionGroup,
+    action_groups.BetaGroupsActionGroup,
+    action_groups.BuildsActionGroup,
+    action_groups.BundleIdsActionGroup,
+    action_groups.CertificatesActionGroup,
+    action_groups.DevicesActionGroup,
+    action_groups.ProfilesActionGroup,
+    action_groups.ReviewSubmissionItemsActionGroup,
+    action_groups.ReviewSubmissionsActionGroup,
+    actions.FetchSigningFilesAction,
+    actions.GetLatestAppStoreBuildNumberAction,
+    actions.GetLatestBuildNumberAction,
+    actions.GetLatestTestflightBuildNumberAction,
+    actions.PublishAction,
+    mixins.ResourceManagerMixin,
+    mixins.SigningFileSaverMixin,
+    PathFinderMixin,
+):
+    """
+    Interact with Apple services via App Store Connect API
+    """
+
+    def __init__(
+        self,
+        key_identifier: Optional[KeyIdentifier],
+        issuer_id: Optional[IssuerId],
+        private_key: Optional[str],
+        log_requests: bool = False,
+        unauthorized_request_retries: int = 1,
+        server_error_retries: int = 1,
+        enable_jwt_cache: bool = False,
+        json_output: bool = False,
+        profiles_directory: pathlib.Path = ProvisioningProfile.DEFAULT_LOCATION,
+        certificates_directory: pathlib.Path = Certificate.DEFAULT_LOCATION,
+        **kwargs,
+    ):
+        super().__init__(**kwargs)
+        self.profiles_directory = profiles_directory
+        self.certificates_directory = certificates_directory
+        self.printer = ResourcePrinter(bool(json_output), self.echo)
+        self._key_identifier = key_identifier
+        self._issuer_id = issuer_id
+        self._private_key = private_key
+        self._log_requests = log_requests
+        self._unauthorized_request_retries = unauthorized_request_retries
+        self._server_error_retries = server_error_retries
+        self._enable_jwt_cache = enable_jwt_cache
+
+    @classmethod
+    def from_cli_args(cls, cli_args: argparse.Namespace) -> AppStoreConnect:
+        key_identifier_argument = AppStoreConnectArgument.KEY_IDENTIFIER.from_args(cli_args)
+        issuer_id_argument = AppStoreConnectArgument.ISSUER_ID.from_args(cli_args)
+        private_key_argument = AppStoreConnectArgument.PRIVATE_KEY.from_args(cli_args)
+        unauthorized_request_retries = Types.ApiUnauthorizedRetries.resolve_value(cli_args.unauthorized_request_retries)
+        server_error_retries = Types.ApiServerErrorRetries.resolve_value(cli_args.server_error_retries)
+        disable_jwt_cache = AppStoreConnectArgument.DISABLE_JWT_CACHE.from_args(cli_args)
+
+        app_store_connect = AppStoreConnect(
+            key_identifier=key_identifier_argument.value if key_identifier_argument else None,
+            issuer_id=issuer_id_argument.value if issuer_id_argument else None,
+            private_key=private_key_argument.value if private_key_argument else None,
+            log_requests=cli_args.log_requests,
+            unauthorized_request_retries=unauthorized_request_retries,
+            server_error_retries=server_error_retries,
+            enable_jwt_cache=not disable_jwt_cache,
+            json_output=cli_args.json_output,
+            profiles_directory=cli_args.profiles_directory,
+            certificates_directory=cli_args.certificates_directory,
+            **cls._parent_class_kwargs(cli_args),
+        )
+
+        cli_action = app_store_connect._get_invoked_cli_action(cli_args)
+        if cli_action.action_options.get("requires_api_client", True):
+            app_store_connect._assert_api_client_credentials()
+
+        return app_store_connect
+
+    def _assert_api_client_credentials(self, custom_error: Optional[str] = None):
+        if self._issuer_id is None:
+            if custom_error:
+                default_message = AppStoreConnectArgument.ISSUER_ID.get_missing_value_error_message()
+                raise AppStoreConnectArgument.ISSUER_ID.raise_argument_error(f"{default_message}. {custom_error}")
+            else:
+                raise AppStoreConnectArgument.ISSUER_ID.raise_argument_error()
+
+        if self._key_identifier is None:
+            if custom_error:
+                default_message = AppStoreConnectArgument.KEY_IDENTIFIER.get_missing_value_error_message()
+                raise AppStoreConnectArgument.KEY_IDENTIFIER.raise_argument_error(f"{default_message}. {custom_error}")
+            else:
+                raise AppStoreConnectArgument.KEY_IDENTIFIER.raise_argument_error()
+
+        try:
+            self._resolve_app_store_connect_private_key()
+        except ValueError as ve:
+            custom_error = ve.args[0] if ve.args else custom_error
+            if custom_error:
+                default_message = AppStoreConnectArgument.PRIVATE_KEY.get_missing_value_error_message()
+                AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error(f"{default_message}. {custom_error}")
+            else:
+                AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error()
+
+    def _resolve_app_store_connect_private_key(self):
+        if self._private_key is not None:
+            return
+
+        for keys_path in Types.PrivateKeyArgument.PRIVATE_KEY_LOCATIONS:
+            try:
+                api_key = next(keys_path.expanduser().glob(f"AuthKey_{self._key_identifier}.p8"))
+            except StopIteration:
+                continue
+
+            try:
+                private_key_argument = Types.PrivateKeyArgument(api_key.read_text())
+            except ValueError:
+                raise ValueError(f"Provided value in {api_key} is not valid")
+            self._private_key = private_key_argument.value
+            break
+        else:
+            raise ValueError()
+
+    def _validate_api_client_key(self, client: AppStoreConnectApiClient):
+        """
+        When running from a CLI context, ensure that App Store Connect API client is using valid
+        private key for JWT generation. In case of invalid key exit with descriptive argument error.
+        """
+
+        if not self.is_cli_invocation():
+            return
+
+        try:
+            client.generate_auth_headers()
+        except jwt.InvalidKeyError:
+            log.get_file_logger(self.__class__).exception("Invalid App Store Connect API key")
+            asc_docs_base_url = "https://developer.apple.com/documentation/appstoreconnectapi"
+            error_message = (
+                "Invalid App Store Connect API key. Make sure to use the private API key downloaded from "
+                "App Store Connect. Read more about creating App Store Connect API keys from "
+                f"{asc_docs_base_url}/creating_api_keys_for_app_store_connect_api"
+            )
+            AppStoreConnectArgument.PRIVATE_KEY.raise_argument_error(error_message)
+
+    @lru_cache(1)
+    def _get_api_client(self) -> AppStoreConnectApiClient:
+        assert self._key_identifier is not None
+        assert self._issuer_id is not None
+        assert self._private_key is not None
+        client = AppStoreConnectApiClient(
+            self._key_identifier,
+            self._issuer_id,
+            self._private_key,
+            log_requests=self._log_requests,
+            unauthorized_request_retries=self._unauthorized_request_retries,
+            server_error_retries=self._server_error_retries,
+            enable_jwt_cache=self._enable_jwt_cache,
+        )
+        self._validate_api_client_key(client)
+        return client
+
+    @property
+    def api_client(self) -> AppStoreConnectApiClient:
+        return self._get_api_client()
+
+
+if __name__ == "__main__":
+    AppStoreConnect.invoke_cli()
diff --git a/src/codemagic/tools/_app_store_connect/arguments.py b/src/codemagic/tools/app_store_connect/arguments.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/arguments.py
rename to src/codemagic/tools/app_store_connect/arguments.py
diff --git a/src/codemagic/tools/_app_store_connect/errors.py b/src/codemagic/tools/app_store_connect/errors.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/errors.py
rename to src/codemagic/tools/app_store_connect/errors.py
diff --git a/src/codemagic/tools/app_store_connect/mixins/__init__.py b/src/codemagic/tools/app_store_connect/mixins/__init__.py
new file mode 100644
index 00000000..fc92c5d4
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/mixins/__init__.py
@@ -0,0 +1,2 @@
+from .resource_manager_mixin import ResourceManagerMixin
+from .signing_file_saver_mixin import SigningFileSaverMixin
diff --git a/src/codemagic/tools/_app_store_connect/resource_manager_mixin.py b/src/codemagic/tools/app_store_connect/mixins/resource_manager_mixin.py
similarity index 98%
rename from src/codemagic/tools/_app_store_connect/resource_manager_mixin.py
rename to src/codemagic/tools/app_store_connect/mixins/resource_manager_mixin.py
index ebc64a5c..936fdb49 100644
--- a/src/codemagic/tools/_app_store_connect/resource_manager_mixin.py
+++ b/src/codemagic/tools/app_store_connect/mixins/resource_manager_mixin.py
@@ -11,8 +11,8 @@
 from codemagic.apple.app_store_connect.resource_manager import ResourceManager
 from codemagic.apple.resources import ResourceId
 
-from .errors import AppStoreConnectError
-from .resource_printer import ResourcePrinter
+from ..errors import AppStoreConnectError
+from ..resource_printer import ResourcePrinter
 
 
 class ResourceManagerMixin:
diff --git a/src/codemagic/tools/app_store_connect/mixins/signing_file_saver_mixin.py b/src/codemagic/tools/app_store_connect/mixins/signing_file_saver_mixin.py
new file mode 100644
index 00000000..67dbc588
--- /dev/null
+++ b/src/codemagic/tools/app_store_connect/mixins/signing_file_saver_mixin.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import pathlib
+import re
+import tempfile
+from typing import List
+from typing import Optional
+from typing import Sequence
+
+from codemagic.apple.resources import Profile
+from codemagic.apple.resources import SigningCertificate
+from codemagic.models import Certificate
+from codemagic.models import PrivateKey
+
+from ..errors import AppStoreConnectError
+from ..resource_printer import ResourcePrinter
+
+
+class SigningFileSaverMixin:
+    certificates_directory: pathlib.Path
+    printer: ResourcePrinter
+    profiles_directory: pathlib.Path
+
+    @classmethod
+    def _get_unique_path(cls, file_name: str, destination: pathlib.Path) -> pathlib.Path:
+        if destination.exists() and not destination.is_dir():
+            raise ValueError(f"Destination {destination} is not a directory")
+        destination.mkdir(parents=True, exist_ok=True)
+        name = pathlib.Path(re.sub(r"[^\w.]", "_", file_name))
+        tf = tempfile.NamedTemporaryFile(
+            prefix=f"{name.stem}_",
+            suffix=name.suffix,
+            dir=destination,
+            delete=False,
+        )
+        tf.close()
+        return pathlib.Path(tf.name)
+
+    def _save_profile(self, profile: Profile) -> pathlib.Path:
+        profile_path = self._get_unique_path(
+            f"{profile.attributes.profileType}_{profile.id}{profile.profile_extension}",
+            self.profiles_directory,
+        )
+        profile_path.write_bytes(profile.profile_content)
+        self.printer.log_saved(profile, profile_path)
+        return profile_path
+
+    def _save_certificate(
+        self,
+        certificate: SigningCertificate,
+        private_key: PrivateKey,
+        p12_container_password: str,
+        certificate_save_path: Optional[pathlib.Path] = None,
+    ) -> pathlib.Path:
+        if certificate_save_path is None:
+            certificate_path = self._get_unique_path(
+                f"{certificate.attributes.certificateType}_{certificate.id}.p12",
+                self.certificates_directory,
+            )
+        else:
+            certificate_path = certificate_save_path
+        try:
+            p12_path = Certificate.from_ans1(certificate.asn1_content).export_p12(
+                private_key,
+                p12_container_password,
+                export_path=certificate_path,
+            )
+        except (ValueError, IOError) as error:
+            raise AppStoreConnectError(*error.args)
+        self.printer.log_saved(certificate, p12_path)
+        return p12_path
+
+    def _save_profiles(self, profiles: Sequence[Profile]) -> List[pathlib.Path]:
+        return [self._save_profile(profile) for profile in profiles]
+
+    def _save_certificates(
+        self,
+        certificates: Sequence[SigningCertificate],
+        private_key: PrivateKey,
+        p12_container_password: str,
+    ) -> List[pathlib.Path]:
+        return [self._save_certificate(c, private_key, p12_container_password) for c in certificates]
diff --git a/src/codemagic/tools/_app_store_connect/resource_printer.py b/src/codemagic/tools/app_store_connect/resource_printer.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/resource_printer.py
rename to src/codemagic/tools/app_store_connect/resource_printer.py


Test patch:
diff --git a/src/codemagic/tools/_app_store_connect/actions/latest_build_number_actions.py b/src/codemagic/tools/app_store_connect/actions/latest_build_number_actions.py
similarity index 100%
rename from src/codemagic/tools/_app_store_connect/actions/latest_build_number_actions.py
rename to src/codemagic/tools/app_store_connect/actions/latest_build_number_actions.py
diff --git a/tests/tools/app_store_connect/arguments/test_beta_build_localizations.py b/tests/tools/app_store_connect/arguments/test_beta_build_localizations.py
index 94ac9ce8..f4b7ff03 100644
--- a/tests/tools/app_store_connect/arguments/test_beta_build_localizations.py
+++ b/tests/tools/app_store_connect/arguments/test_beta_build_localizations.py
@@ -6,8 +6,8 @@
 import pytest
 from codemagic.apple.resources import Locale
 from codemagic.models.enums import ResourceEnumMeta
-from codemagic.tools.app_store_connect import BetaBuildInfo
-from codemagic.tools.app_store_connect import Types
+from codemagic.tools.app_store_connect.arguments import BetaBuildInfo
+from codemagic.tools.app_store_connect.arguments import Types
 
 
 @pytest.fixture()
diff --git a/tests/tools/app_store_connect/conftest.py b/tests/tools/app_store_connect/conftest.py
index 7c6b19ce..8c167355 100644
--- a/tests/tools/app_store_connect/conftest.py
+++ b/tests/tools/app_store_connect/conftest.py
@@ -5,8 +5,8 @@
 
 import pytest
 from codemagic.tools.app_store_connect import AppStoreConnect
-from codemagic.tools.app_store_connect import AppStoreConnectArgument
-from codemagic.tools.app_store_connect import Types
+from codemagic.tools.app_store_connect.arguments import AppStoreConnectArgument
+from codemagic.tools.app_store_connect.arguments import Types
 
 
 @pytest.fixture(autouse=True)
@@ -24,7 +24,8 @@ def mock_auth_key() -> pathlib.Path:
 def namespace_kwargs(mock_auth_key):
     args = AppStoreConnectArgument
     ns_kwargs = {
-        "action": "list-devices",
+        "action": "devices",
+        "action_subcommand": "list",
         args.CERTIFICATES_DIRECTORY.key: args.CERTIFICATES_DIRECTORY.get_default(),
         args.PROFILES_DIRECTORY.key: args.PROFILES_DIRECTORY.get_default(),
         args.LOG_REQUESTS.key: True,
diff --git a/tests/tools/app_store_connect/test_from_cli_args.py b/tests/tools/app_store_connect/test_from_cli_args.py
index 0a6a7c8b..38068bd6 100644
--- a/tests/tools/app_store_connect/test_from_cli_args.py
+++ b/tests/tools/app_store_connect/test_from_cli_args.py
@@ -7,8 +7,8 @@
 
 import pytest
 from codemagic.tools.app_store_connect import AppStoreConnect
-from codemagic.tools.app_store_connect import AppStoreConnectArgument
-from codemagic.tools.app_store_connect import Types
+from codemagic.tools.app_store_connect.arguments import AppStoreConnectArgument
+from codemagic.tools.app_store_connect.arguments import Types
 
 
 def _test_missing_argument(argument, _namespace_kwargs):
@@ -46,7 +46,7 @@ def test_missing_private_key_arg(namespace_kwargs):
         (AppStoreConnectArgument.ISSUER_ID, 1),
     ],
 )
-@mock.patch("codemagic.tools.app_store_connect.AppStoreConnectApiClient")
+@mock.patch("codemagic.tools.app_store_connect.app_store_connect.AppStoreConnectApiClient")
 def test_missing_arg_from_env(mock_appstore_api_client, namespace_kwargs, argument, api_client_arg_index):
     namespace_kwargs[argument.key] = None
 
@@ -110,7 +110,7 @@ def test_private_key_invalid_path(namespace_kwargs):
     assert str(exception_info.value) == 'argument --private-key: File "this-is-not-a-file" does not exist'
 
 
-@mock.patch("codemagic.tools.app_store_connect.AppStoreConnectApiClient")
+@mock.patch("codemagic.tools.app_store_connect.app_store_connect.AppStoreConnectApiClient")
 def test_read_private_key(mock_appstore_api_client, namespace_kwargs, mock_auth_key):
     pk = mock_auth_key.read_text()
     namespace_kwargs[AppStoreConnectArgument.PRIVATE_KEY.key] = Types.PrivateKeyArgument(pk)
@@ -128,7 +128,7 @@ def test_read_private_key(mock_appstore_api_client, namespace_kwargs, mock_auth_
         ),
     ],
 )
-@mock.patch("codemagic.tools.app_store_connect.AppStoreConnectApiClient")
+@mock.patch("codemagic.tools.app_store_connect.app_store_connect.AppStoreConnectApiClient")
 def test_private_key_path_arg(mock_appstore_api_client, configure_variable, namespace_kwargs, mock_auth_key):
     pk = mock_auth_key.read_text()
     with NamedTemporaryFile(mode="w") as tf:
@@ -150,7 +150,7 @@ def test_private_key_path_arg(mock_appstore_api_client, configure_variable, name
         ),
     ],
 )
-@mock.patch("codemagic.tools.app_store_connect.AppStoreConnectApiClient")
+@mock.patch("codemagic.tools.app_store_connect.app_store_connect.AppStoreConnectApiClient")
 def test_private_key_env_arg(mock_appstore_api_client, configure_variable, namespace_kwargs, mock_auth_key):
     pk = mock_auth_key.read_text()
     os.environ["PK_VALUE"] = pk
diff --git a/tests/tools/app_store_connect/test_publish_action.py b/tests/tools/app_store_connect/test_publish_action.py
index 2ecaeb88..6b3b68ef 100644
--- a/tests/tools/app_store_connect/test_publish_action.py
+++ b/tests/tools/app_store_connect/test_publish_action.py
@@ -11,9 +11,9 @@
 from codemagic.apple.resources import ReleaseType
 from codemagic.models.application_package import Ipa
 from codemagic.tools.app_store_connect import AppStoreConnect
-from codemagic.tools.app_store_connect import AppStoreConnectArgument
-from codemagic.tools.app_store_connect import PublishArgument
-from codemagic.tools.app_store_connect import Types
+from codemagic.tools.app_store_connect.arguments import AppStoreConnectArgument
+from codemagic.tools.app_store_connect.arguments import PublishArgument
+from codemagic.tools.app_store_connect.arguments import Types
 
 
 @pytest.fixture()
@@ -23,6 +23,7 @@ def publishing_namespace_kwargs(namespace_kwargs):
             "action": "publish",
         },
     )
+    namespace_kwargs.pop("action_subcommand", None)
     return namespace_kwargs
 
 
@@ -82,7 +83,7 @@ def test_publish_action_without_app_store_connect_key_and_beta_locales(missing_a
     assert missing_argument.flag in error_info.value.argument_name
 
 
-@mock.patch("codemagic.tools._app_store_connect.actions.publish_action.Altool")
+@mock.patch("codemagic.tools.app_store_connect.actions.publish_action.Altool")
 def test_publish_action_with_username_and_password(_mock_altool, publishing_namespace_kwargs):
     publishing_namespace_kwargs.update(
         {
@@ -256,7 +257,7 @@ def test_publish_action_app_store_submit(publishing_namespace_kwargs):
         mock_add_beta_test_info.assert_not_called()
 
 
-@mock.patch("codemagic.tools._app_store_connect.actions.publish_action.Altool")
+@mock.patch("codemagic.tools.app_store_connect.actions.publish_action.Altool")
 @pytest.mark.parametrize(
     "enable_package_validation, should_validate",
     [