diff --git a/cumulusci/core/config/org_config.py b/cumulusci/core/config/org_config.py index e179fbbe3b..75e6887611 100644 --- a/cumulusci/core/config/org_config.py +++ b/cumulusci/core/config/org_config.py @@ -318,10 +318,16 @@ def has_minimum_package_version(self, package_identifier, version_identifier): @property def installed_packages(self): - """installed_packages is a dict mapping a namespace or package Id (033*) to the installed package + """installed_packages is a dict mapping a namespace, package name, or package Id (033*) to the installed package version(s) matching that identifier. All values are lists, because multiple second-generation packages may be installed with the same namespace. + Keys include: + - namespace: "mycompany" + - package name: "My Package Name" + - namespace@version: "mycompany@1.2.3" + - package ID: "033ABCDEF123456" + To check if a required package is present, call `has_minimum_package_version()` with either the namespace or 033 Id of the desired package and its version, in 1.2.3 format. @@ -329,7 +335,7 @@ def installed_packages(self): """ if self._installed_packages is None: isp_result = self.salesforce_client.restful( - "tooling/query/?q=SELECT SubscriberPackage.Id, SubscriberPackage.NamespacePrefix, " + "tooling/query/?q=SELECT SubscriberPackage.Id, SubscriberPackage.Name, SubscriberPackage.NamespacePrefix, " "SubscriberPackageVersionId FROM InstalledSubscriberPackage" ) _installed_packages = defaultdict(list) @@ -357,10 +363,14 @@ def installed_packages(self): version += f"b{spv['BuildNumber']}" version_info = VersionInfo(spv["Id"], StrictVersion(version)) namespace = sp["NamespacePrefix"] + package_name = sp["Name"] _installed_packages[namespace].append(version_info) namespace_version = f"{namespace}@{version}" _installed_packages[namespace_version].append(version_info) _installed_packages[sp["Id"]].append(version_info) + # Add package name as a key for specific package detection + if package_name: + _installed_packages[package_name].append(version_info) self._installed_packages = _installed_packages return self._installed_packages diff --git a/cumulusci/core/utils.py b/cumulusci/core/utils.py index 88cd570657..5e2a0b3a71 100644 --- a/cumulusci/core/utils.py +++ b/cumulusci/core/utils.py @@ -362,3 +362,36 @@ def make_jsonable(x): return x except (TypeError, OverflowError): return str(x) + + +def determine_managed_mode(options, project_config, org_config): + """Determine the managed mode based on options, project config, and org config. + + Args: + options: Dict of task options that may contain 'managed' or 'unmanaged' flags + project_config: Project configuration object with package info + org_config: Org configuration object with installed packages and namespace info + + Returns: + bool: True if in managed mode, False if in unmanaged mode + """ + if "managed" in options: + return process_bool_arg(options["managed"]) + + # Get package and namespace information + package_name = getattr(project_config, 'project__package__name', None) + namespace = getattr(project_config, 'project__package__namespace', None) + installed_packages = getattr(org_config, 'installed_packages', {}) + + if "unmanaged" in options: + # Explicit unmanaged flag always takes precedence + return not process_bool_arg(options.get("unmanaged", True)) + elif package_name and any(package_name in key for key in installed_packages.keys()): + # If this specific package is installed (or there is any installed package with a Name that contains package_name), we're in managed context + return True + elif bool(namespace) and namespace == getattr(org_config, 'namespace', None): + # We're in a namespaced org (packaging org) developing unmanaged code + return False + else: + # Fall back to checking namespace in installed packages + return bool(namespace) and namespace in installed_packages diff --git a/cumulusci/tasks/apex/anon.py b/cumulusci/tasks/apex/anon.py index 56bea2a4c4..378f91ce28 100644 --- a/cumulusci/tasks/apex/anon.py +++ b/cumulusci/tasks/apex/anon.py @@ -4,7 +4,7 @@ SalesforceException, TaskOptionsError, ) -from cumulusci.core.utils import process_bool_arg +from cumulusci.core.utils import process_bool_arg, determine_managed_mode from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import in_directory, inject_namespace from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -107,12 +107,9 @@ def _process_apex_string(self, apex_string): def _prepare_apex(self, apex): # Process namespace tokens namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced" in self.options: namespaced = process_bool_arg(self.options["namespaced"]) else: diff --git a/cumulusci/tasks/apex/testrunner.py b/cumulusci/tasks/apex/testrunner.py index 5a51f655ed..d594370347 100644 --- a/cumulusci/tasks/apex/testrunner.py +++ b/cumulusci/tasks/apex/testrunner.py @@ -10,7 +10,7 @@ CumulusCIException, TaskOptionsError, ) -from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg +from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg, determine_managed_mode from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -609,13 +609,9 @@ def _enqueue_test_run(self, class_ids): def _init_task(self): super()._init_task() - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - namespace = self.options.get("namespace") - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) def _run_task(self): result = self._get_test_classes() diff --git a/cumulusci/tasks/metadata_etl/base.py b/cumulusci/tasks/metadata_etl/base.py index d7e9dbd849..d11cdf0d91 100644 --- a/cumulusci/tasks/metadata_etl/base.py +++ b/cumulusci/tasks/metadata_etl/base.py @@ -7,7 +7,7 @@ from cumulusci.core.enums import StrEnum from cumulusci.core.exceptions import CumulusCIException, TaskOptionsError from cumulusci.core.tasks import BaseSalesforceTask -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode from cumulusci.salesforce_api.metadata import ApiRetrieveUnpackaged from cumulusci.tasks.metadata.package import PackageXmlGenerator from cumulusci.utils import inject_namespace @@ -66,19 +66,16 @@ def _init_namespace_injection(self): self.options.get("namespace_inject") or self.project_config.project__package__namespace ) - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced_org" in self.options: self.options["namespaced_org"] = process_bool_arg( self.options["namespaced_org"] or False ) else: self.options["namespaced_org"] = ( - bool(namespace) and namespace == self.org_config.namespace + bool(namespace) and namespace == getattr(self.org_config, 'namespace', None) ) def _inject_namespace(self, text): diff --git a/cumulusci/tasks/salesforce/Deploy.py b/cumulusci/tasks/salesforce/Deploy.py index 66a5851b80..196204715e 100644 --- a/cumulusci/tasks/salesforce/Deploy.py +++ b/cumulusci/tasks/salesforce/Deploy.py @@ -12,7 +12,7 @@ SourceTransform, SourceTransformList, ) -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode from cumulusci.salesforce_api.metadata import ApiDeploy, ApiRetrieveUnpackaged from cumulusci.salesforce_api.package_zip import MetadataPackageZipBuilder from cumulusci.salesforce_api.rest_deploy import RestDeploy @@ -154,9 +154,9 @@ def _get_api(self, path=None): ) def _has_namespaced_package(self, ns: Optional[str]) -> bool: - if "unmanaged" in self.options: - return not process_bool_arg(self.options.get("unmanaged", True)) - return bool(ns) and ns in self.org_config.installed_packages + return determine_managed_mode( + self.options, self.project_config, self.org_config + ) def _is_namespaced_org(self, ns: Optional[str]) -> bool: if "namespaced_org" in self.options: diff --git a/cumulusci/tasks/salesforce/composite.py b/cumulusci/tasks/salesforce/composite.py index cd83202221..2634faa2a0 100644 --- a/cumulusci/tasks/salesforce/composite.py +++ b/cumulusci/tasks/salesforce/composite.py @@ -6,7 +6,7 @@ from cumulusci.cli.ui import CliTable from cumulusci.core.exceptions import SalesforceException -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import inject_namespace @@ -83,12 +83,9 @@ def _process_json(self, body): body = body.replace("%%%USERID%%%", user_id) namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) _, body = inject_namespace( "composite", diff --git a/cumulusci/tasks/salesforce/custom_settings_wait.py b/cumulusci/tasks/salesforce/custom_settings_wait.py index 77432d5517..d47aa0cd3f 100644 --- a/cumulusci/tasks/salesforce/custom_settings_wait.py +++ b/cumulusci/tasks/salesforce/custom_settings_wait.py @@ -3,7 +3,7 @@ from simple_salesforce.exceptions import SalesforceError from cumulusci.core.exceptions import TaskOptionsError -from cumulusci.core.utils import process_bool_arg +from cumulusci.core.utils import process_bool_arg, determine_managed_mode from cumulusci.tasks.salesforce import BaseSalesforceApiTask @@ -93,12 +93,9 @@ def _poll_again(self): def _apply_namespace(self): # Process namespace tokens namespace = self.project_config.project__package__namespace - if "managed" in self.options: - managed = process_bool_arg(self.options["managed"]) - else: - managed = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + managed = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced" in self.options: namespaced = process_bool_arg(self.options["namespaced"]) else: diff --git a/cumulusci/tasks/salesforce/enable_prediction.py b/cumulusci/tasks/salesforce/enable_prediction.py index bc99463e30..8a8d3f41b2 100644 --- a/cumulusci/tasks/salesforce/enable_prediction.py +++ b/cumulusci/tasks/salesforce/enable_prediction.py @@ -1,7 +1,7 @@ from simple_salesforce.exceptions import SalesforceError from cumulusci.core.exceptions import CumulusCIException -from cumulusci.core.utils import process_bool_arg, process_list_arg +from cumulusci.core.utils import process_bool_arg, process_list_arg, determine_managed_mode from cumulusci.tasks.salesforce import BaseSalesforceApiTask from cumulusci.utils import inject_namespace from cumulusci.utils.http.requests_utils import safe_json_from_response @@ -37,12 +37,9 @@ def _init_namespace_injection(self): or self.project_config.project__package__namespace ) self.options["namespace_inject"] = namespace - if "managed" in self.options: - self.options["managed"] = process_bool_arg(self.options["managed"] or False) - else: - self.options["managed"] = ( - bool(namespace) and namespace in self.org_config.installed_packages - ) + self.options["managed"] = determine_managed_mode( + self.options, self.project_config, self.org_config + ) if "namespaced_org" in self.options: self.options["namespaced_org"] = process_bool_arg( self.options["namespaced_org"] or False diff --git a/cumulusci/tasks/salesforce/sourcetracking.py b/cumulusci/tasks/salesforce/sourcetracking.py index 903cd3595c..0d3881e76f 100644 --- a/cumulusci/tasks/salesforce/sourcetracking.py +++ b/cumulusci/tasks/salesforce/sourcetracking.py @@ -248,6 +248,7 @@ def retrieve_components( to a namespace prefix to replace it with a `%%%NAMESPACE%%%` token. """ + # Resolve output_dir if provided; otherwise let sfdx choose defaults retrieve_target = os.path.realpath(output_dir) if output_dir else None profiles = [] diff --git a/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py b/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py index efd3e76977..aff43dcf3a 100644 --- a/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py +++ b/cumulusci/tasks/salesforce/tests/test_ProfileGrantAllAccess.py @@ -591,7 +591,51 @@ def test_init_options__namespace_injection(): ) assert task.options["namespace_inject"] == "ns" assert task.options["namespaced_org"] - assert task.options["managed"] + assert not task.options["managed"] # Fixed: namespaced org should be unmanaged + + +def test_init_options__managed_explicit_unmanaged_flag(): + """Test that explicit unmanaged=True forces managed=False even with installed packages.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "other"}) + org_config._installed_packages = {"ns": None} # Package is installed + task = create_task( + ProfileGrantAllAccess, {"unmanaged": True}, project_config=pc, org_config=org_config + ) + assert not task.options["managed"] # Should be False due to explicit unmanaged=True + + +def test_init_options__managed_explicit_unmanaged_false(): + """Test that explicit unmanaged=False forces managed=True.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "other"}) + org_config._installed_packages = {} # No packages installed + task = create_task( + ProfileGrantAllAccess, {"unmanaged": False}, project_config=pc, org_config=org_config + ) + assert task.options["managed"] # Should be True due to explicit unmanaged=False + + +def test_init_options__managed_fallback_to_installed_packages(): + """Test that we fall back to installed packages check when not in namespaced org.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "different"}) # Different namespace + org_config._installed_packages = {"ns": None} # But package is installed + task = create_task( + ProfileGrantAllAccess, {}, project_config=pc, org_config=org_config + ) + assert task.options["managed"] # Should be True due to installed package + + +def test_init_options__managed_no_installed_package(): + """Test that managed=False when package is not installed and not in namespaced org.""" + pc = create_project_config(namespace="ns") + org_config = DummyOrgConfig({"namespace": "different"}) + org_config._installed_packages = {} # No packages installed + task = create_task( + ProfileGrantAllAccess, {}, project_config=pc, org_config=org_config + ) + assert not task.options["managed"] # Should be False - no package installed def test_generate_package_xml__retrieve(): diff --git a/cumulusci/utils/__init__.py b/cumulusci/utils/__init__.py index 2d740c40cb..b3a987e2d7 100644 --- a/cumulusci/utils/__init__.py +++ b/cumulusci/utils/__init__.py @@ -263,6 +263,14 @@ def inject_namespace( logger.info( f' {name}: Replaced {filename_token} with "{namespace_prefix}"' ) + + # Also replace ___NAMESPACED_ORG___ tokens in package.xml + prev_content = content + content = content.replace(namespaced_org_file_token, namespaced_org) + if logger and content != prev_content: + logger.info( + f' {name}: Replaced {namespaced_org_file_token} with "{namespaced_org}"' + ) prev_content = content content = content.replace(namespaced_org_token, namespaced_org) diff --git a/docs/history.md b/docs/history.md index 5178fb36cd..fa4f312102 100644 --- a/docs/history.md +++ b/docs/history.md @@ -2,6 +2,7 @@ + ## v4.5.0 (2025-08-06) @@ -1828,6 +1829,7 @@ Critical Changes: subfolders will see a change in resolution behavior. Previously, a dependency specified like this: + dependencies: - github: https://github.com/SalesforceFoundation/NPSP subfolder: unpackaged/config/trial