From b45d3a1b475b15b27165125d8c3de59a8a8f574b Mon Sep 17 00:00:00 2001 From: Oliver Heger Date: Mon, 27 May 2024 17:47:51 +0200 Subject: [PATCH] feat(Yarn): Add basic support for Corepack According to the Yarn 2+ documentation, Corepack [1] is the preferred way to install this package manager. If this method is used, the name of the executable has to be determined differently. [1]: https://yarnpkg.com/corepack Signed-off-by: Oliver Heger --- .../node/src/main/kotlin/Yarn2.kt | 47 +++++- .../node/src/test/kotlin/Yarn2Test.kt | 151 ++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt diff --git a/plugins/package-managers/node/src/main/kotlin/Yarn2.kt b/plugins/package-managers/node/src/main/kotlin/Yarn2.kt index fe31babb2e9a9..dd3cb382e1135 100644 --- a/plugins/package-managers/node/src/main/kotlin/Yarn2.kt +++ b/plugins/package-managers/node/src/main/kotlin/Yarn2.kt @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.MappingIterator import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.contains import com.fasterxml.jackson.module.kotlin.readValues import java.io.File @@ -87,6 +88,10 @@ private enum class YarnDependencyType(val type: String) { * - *disableRegistryCertificateVerification*: If true, the `yarn npm info` commands called by this package manager will * not verify the server certificate of the HTTPS connection to the NPM registry. This allows to replace the latter by * a local one, e.g. for intercepting the requests or replaying them. + * - *corepackOverride*: Per default, this class determines via auto-detection whether Yarn has been installed via + * [Corepack](https://yarnpkg.com/corepack), which impacts the name of the executable to use. With this option, + * auto-detection can be disabled, and the enabled status of Corepack can be explicitly specified. This is useful to + * force a specific behavior in some environments. */ class Yarn2( name: String, @@ -100,6 +105,11 @@ class Yarn2( */ const val OPTION_DISABLE_REGISTRY_CERTIFICATE_VERIFICATION = "disableRegistryCertificateVerification" + /** + * The name of the option that allows overriding the automatic detection of Corepack. + */ + const val OPTION_COREPACK_OVERRIDE = "corepackOverride" + /** * The name of Yarn 2+ resource file. */ @@ -119,10 +129,35 @@ class Yarn2( * The amount of package details to query at once with `yarn npm info`. */ private const val BULK_DETAILS_SIZE = 1000 + + /** + * The name of the manifest file used by Yarn 2+. + */ + private const val MANIFEST_FILE = "package.json" + + /** + * The name of the property that defines the package manager and its version if Corepack is enabled. + */ + private const val PACKAGE_MANAGER_PROPERTY = "packageManager" + + /** + * The name of the default executable. This is used when the [OPTION_COREPACK_OVERRIDE] option is set. + */ + private const val DEFAULT_EXECUTABLE_NAME = "yarn" + + /** + * Check whether Corepack is enabled based on the `package.json` file in [workingDir]. If no such file is found + * or if it cannot be read, assume that this is not the case. + */ + private fun isCorepackEnabledInManifest(workingDir: File): Boolean { + return runCatching { + jsonMapper.readTree(workingDir.resolve(MANIFEST_FILE)).contains(PACKAGE_MANAGER_PROPERTY) + }.getOrDefault(false) + } } class Factory : AbstractPackageManagerFactory("Yarn2") { - override val globsForDefinitionFiles = listOf("package.json") + override val globsForDefinitionFiles = listOf(MANIFEST_FILE) override fun create( analysisRoot: File, @@ -135,7 +170,8 @@ class Yarn2( * The Yarn 2+ executable is not installed globally: The program shipped by the project in `.yarn/releases` is used * instead. The value of the 'yarnPath' property in the resource file `.yarnrc.yml` defines the path to the * executable for the current project e.g. `yarnPath: .yarn/releases/yarn-3.2.1.cjs`. - * This map holds the mapping between the directory and their Yarn 2+ executables. + * This map holds the mapping between the directory and their Yarn 2+ executables. It is only used if Yarn has not + * been installed via Corepack; then it is accessed under a default name. */ private val yarn2ExecutablesByPath: MutableMap = mutableMapOf() @@ -157,6 +193,13 @@ class Yarn2( override fun command(workingDir: File?): String { if (workingDir == null) return "" + val corepackEnabled = if (OPTION_COREPACK_OVERRIDE in options) { + options[OPTION_COREPACK_OVERRIDE].toBoolean() + } else { + isCorepackEnabledInManifest(workingDir) + } + if (corepackEnabled) return DEFAULT_EXECUTABLE_NAME + return yarn2ExecutablesByPath.getOrPut(workingDir) { val yarnConfig = yamlMapper.readTree(workingDir.resolve(YARN2_RESOURCE_FILE)) val yarnCommand = requireNotNull(yarnConfig[YARN_PATH_PROPERTY_NAME]) { diff --git a/plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt b/plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt new file mode 100644 index 0000000000000..7c872a1e10235 --- /dev/null +++ b/plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.packagemanagers.node + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.WordSpec +import io.kotest.engine.spec.tempdir +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +import java.io.File + +import org.ossreviewtoolkit.model.config.AnalyzerConfiguration +import org.ossreviewtoolkit.model.config.PackageManagerConfiguration +import org.ossreviewtoolkit.model.config.RepositoryConfiguration + +class Yarn2Test : WordSpec() { + init { + "command" should { + "return the executable defined in .yarnrc.yml if no package.json is present" { + checkExecutableFromYarnRc(tempdir()) + } + + "return the executable defined in .yarnrc.yml if no package manager is defined" { + val workingDir = tempdir() + writePackageJson(workingDir, null) + + checkExecutableFromYarnRc(workingDir) + } + + "return the executable defined in .yarnrc.yml if package.json is invalid" { + val workingDir = tempdir() + workingDir.resolve("package.json").writeText("invalid-json") + + checkExecutableFromYarnRc(workingDir) + } + + "throw if no executable is defined in .yarnrc.yml" { + val workingDir = tempdir() + workingDir.resolve(".yarnrc.yml").writeText("someProperty: some-value") + + val yarn = Yarn2("yarn", workingDir, AnalyzerConfiguration(), RepositoryConfiguration()) + + val exception = shouldThrow { + yarn.command(workingDir) + } + + exception.localizedMessage shouldContain "No Yarn 2+ executable" + } + + "throw if the executable defined in .yarnrc.yml does not exist" { + val workingDir = tempdir() + val executable = "non-existing-yarn-wrapper.js" + workingDir.resolve(".yarnrc.yml").writeText("yarnPath: $executable") + + val yarn = Yarn2("yarn", workingDir, AnalyzerConfiguration(), RepositoryConfiguration()) + + val exception = shouldThrow { + yarn.command(workingDir) + } + + exception.localizedMessage shouldContain executable + } + + "return the default executable name if Corepack is enabled based on the configuration option" { + val workingDir = tempdir() + val yarn2Options = mapOf("corepackOverride" to "true") + val analyzerConfiguration = AnalyzerConfiguration( + packageManagers = mapOf("Yarn2" to PackageManagerConfiguration(options = yarn2Options)) + ) + + val yarn = Yarn2("Yarn2", workingDir, analyzerConfiguration, RepositoryConfiguration()) + val command = yarn.command(workingDir) + + command shouldBe "yarn" + } + + "return the default executable name if Corepack is enabled based on the package.json" { + val workingDir = tempdir() + writePackageJson(workingDir, "yarn@4.0.0") + + val yarn = Yarn2("Yarn2", workingDir, AnalyzerConfiguration(), RepositoryConfiguration()) + val command = yarn.command(workingDir) + + command shouldBe "yarn" + } + + "return the executable defined in .yarnrc.yml if Corepack detection is turned off" { + val workingDir = tempdir() + writePackageJson(workingDir, "yarn@4.0.0") + + val yarn2Options = mapOf("corepackOverride" to "false") + val analyzerConfiguration = AnalyzerConfiguration( + packageManagers = mapOf("Yarn2" to PackageManagerConfiguration(options = yarn2Options)) + ) + + checkExecutableFromYarnRc(workingDir, analyzerConfiguration) + } + } + } + + /** + * Check whether an executable defined in a `.yarnrc.yml` file is used when invoked with the given [workingDir] + * and [config]. This should be the case when Corepack is not enabled. + */ + private fun checkExecutableFromYarnRc(workingDir: File, config: AnalyzerConfiguration = AnalyzerConfiguration()) { + val executable = "yarn-wrapper.js" + workingDir.resolve(".yarnrc.yml").writeText("yarnPath: $executable") + val executableFile = workingDir.resolve(executable).apply { + writeText("#!/usr/bin/env node\nconsole.log('yarn')") + } + + val yarn = Yarn2("Yarn2", workingDir, config, RepositoryConfiguration()) + val command = yarn.command(workingDir) + + command shouldBe executableFile.absolutePath + } +} + +/** + * Write a `package.json` file to [dir] with some default properties and an optional [packageManager] entry. + */ +private fun writePackageJson(dir: File, packageManager: String?) { + val packageManagerProperty = packageManager?.let { """"packageManager": "$it"""" }.orEmpty() + dir.resolve("package.json").writeText( + """ + { + "name": "test", + "version": "1.0.0", + $packageManagerProperty + } + """.trimIndent() + ) +}