Skip to content

Commit

Permalink
feat(Yarn): Add basic support for Corepack
Browse files Browse the repository at this point in the history
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 <[email protected]>
oheger-bosch authored and sschuberth committed Jun 3, 2024
1 parent e3ec11e commit 3e9d8f4
Showing 2 changed files with 196 additions and 2 deletions.
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
@@ -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>("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<File, String> = 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]) {
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() {
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, "[email protected]")

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, "[email protected]")

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()
)
}

0 comments on commit 3e9d8f4

Please sign in to comment.