Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Yarn): Add basic support for Corepack [1]. #8703

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions plugins/package-managers/node/src/main/kotlin/Yarn2.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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.
*/
Expand All @@ -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>("Yarn2") {
override val globsForDefinitionFiles = listOf("package.json")
override val globsForDefinitionFiles = listOf(MANIFEST_FILE)

override fun create(
analysisRoot: File,
Expand All @@ -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<File, String> = mutableMapOf()

Expand All @@ -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]) {
Expand Down
151 changes: 151 additions & 0 deletions plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* Copyright (C) 2024 The ORT Project Authors (see <https://github.com/oss-review-toolkit/ort/blob/main/NOTICE>)
*
* 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() {

Check warning on line 34 in plugins/package-managers/node/src/test/kotlin/Yarn2Test.kt

View workflow job for this annotation

GitHub Actions / qodana-scan

Unused symbol

Class "Yarn2Test" is never used
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<IllegalArgumentException> {
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<IllegalArgumentException> {
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()
)
}
Loading