Skip to content
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
250 changes: 194 additions & 56 deletions artifactory/commands/yarn/yarn.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"bufio"
"encoding/json"
"errors"
"fmt"
"sync"

"github.com/jfrog/build-info-go/entities"
"github.com/jfrog/build-info-go/flexpack"
gofrogio "github.com/jfrog/gofrog/io"
"github.com/jfrog/jfrog-cli-core/v2/common/format"
"github.com/jfrog/jfrog-client-go/artifactory"
"github.com/jfrog/jfrog-client-go/artifactory/services"
servicesUtils "github.com/jfrog/jfrog-client-go/artifactory/services/utils"
Expand All @@ -27,6 +30,7 @@ import (
"github.com/jfrog/jfrog-cli-core/v2/utils/config"
"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"
"github.com/jfrog/gofrog/version"
"github.com/jfrog/jfrog-client-go/auth"
"github.com/jfrog/jfrog-client-go/utils/errorutils"
"github.com/jfrog/jfrog-client-go/utils/log"
Expand All @@ -43,6 +47,11 @@ const (
// #nosec G101
yarnNpmAuthToken = "YARN_NPM_AUTH_TOKEN"
yarnNpmAlwaysAuth = "YARN_NPM_ALWAYS_AUTH"

// maxSupportedYarnVersion is the highest major version of Yarn that is supported.
// Yarn v4 (Berry) is now fully supported.
maxSupportedYarnVersion = "4"
nextMajorYarnVersion = "5.0.0"
)

type YarnCommand struct {
Expand All @@ -59,6 +68,7 @@ type YarnCommand struct {
serverDetails *config.ServerDetails
buildConfiguration *buildUtils.BuildConfiguration
buildInfoModule *build.YarnModule
useNative bool
}

func NewYarnCommand() *YarnCommand {
Expand All @@ -75,31 +85,144 @@ func (yc *YarnCommand) SetArgs(args []string) *YarnCommand {
return yc
}

func (yc *YarnCommand) SetUseNative(useNative bool) *YarnCommand {
yc.useNative = useNative
return yc
}

func (yc *YarnCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *YarnCommand {
yc.buildConfiguration = buildConfiguration
return yc
}

func (yc *YarnCommand) SetServerDetails(serverDetails *config.ServerDetails) *YarnCommand {
yc.serverDetails = serverDetails
return yc
}

func (yc *YarnCommand) Run() (err error) {
log.Info("Running Yarn...")
if err = yc.validateSupportedCommand(); err != nil {
log.Debug("Error occurred while validating the command with args: ", yc.yarnArgs, " with error: ", err)
return
}

if err = yc.readConfigFile(); err != nil {
return
// Native mode (JFROG_RUN_NATIVE=true) is supported from yarn v4 onwards.
// v2/v3 must use the configured-registry path with a jfrog config file.
if flexpack.IsFlexPackEnabled() {
isV4, vErr := yc.isYarnV4OrAbove()
if vErr != nil {
// yarn not found in PATH or version check failed — fail fast with a clear error
// rather than silently falling back to configured-registry mode which will crash
// with a misleading "open: no such file or directory" error.
return fmt.Errorf("JFROG_RUN_NATIVE=true requires yarn in PATH: %w", vErr)
}
if isV4 {
yc.useNative = true
log.Info("Running yarn in native mode (JFROG_RUN_NATIVE=true)")
} else {
log.Warn("Native mode (JFROG_RUN_NATIVE=true) is supported from yarn v4 onwards. Falling back to configured-registry mode.")
}
}

if yc.useNative {
return yc.runNative()
}
return yc.runWithConfiguredRegistry()
}

// isYarnV4OrAbove returns true when the installed yarn version is v4.0.0 or above.
func (yc *YarnCommand) isYarnV4OrAbove() (bool, error) {
yarnExecPath, err := exec.LookPath("yarn")
if err != nil {
return false, err
}
cmd := exec.Command(yarnExecPath, "--version")
out, err := cmd.Output()
if err != nil {
return false, err
}
v := version.NewVersion(strings.TrimSpace(string(out)))
return v.AtLeast("4.0.0"), nil
}

// runNative runs yarn using the user's own .yarnrc.yml authentication configuration.
// This mode is activated when JFROG_RUN_NATIVE=true. It skips all Artifactory auth
// configuration and .yarnrc backup/restore, trusting the user's existing setup.
//
// Dependency tree collection works the same way via `yarn info --all --recursive --json`.
// Server details for checksum collection are resolved from --server-id or the default server.
func (yc *YarnCommand) runNative() (err error) {
var filteredYarnArgs []string
yc.threads, _, _, _, filteredYarnArgs, yc.buildConfiguration, err = extractYarnOptionsFromArgs(yc.yarnArgs)
yc.threads, filteredYarnArgs, yc.buildConfiguration, err = extractYarnOptionsFromArgs(yc.yarnArgs)
if err != nil {
log.Debug("Error occurred while extracting yarn opts: ", err)
return
}

// Extract --server-id from args; if empty, GetSpecificConfig returns the default server.
filteredYarnArgs, serverID, err := coreutils.ExtractServerIdFromCommand(filteredYarnArgs)
if err != nil {
return
}
if yc.serverDetails == nil {
yc.serverDetails, err = config.GetSpecificConfig(serverID, true, true)
if err != nil {
return
}
}

if err = yc.preparePrerequisites(); err != nil {
return
}

err = verifyYarnVersion(yc.executablePath, filteredYarnArgs)
var missingDepsChan chan string
var missingDependencies []string
if yc.collectBuildInfo {
missingDepsChan, err = yc.prepareBuildInfo()
if err != nil {
return
}
go func() {
for depId := range missingDepsChan {
missingDependencies = append(missingDependencies, depId)
}
}()
}

Comment thread
fluxxBot marked this conversation as resolved.
// In native mode, we use the user's own .yarnrc.yml as-is — no backup/restore needed.
yc.buildInfoModule.SetArgs(filteredYarnArgs)
if err = yc.buildInfoModule.Build(); err != nil {
if yc.collectBuildInfo {
close(missingDepsChan)
}
return
}

if yc.collectBuildInfo {
close(missingDepsChan)
printMissingDependencies(missingDependencies)
}

return
}

// runWithConfiguredRegistry runs yarn after configuring the registry and auth
// from the project's jfrog config file. It sets YARN_NPM_* environment variables
// and modifies .yarnrc.yml to point at the configured Artifactory repository,
// then restores everything after the build completes.
func (yc *YarnCommand) runWithConfiguredRegistry() (err error) {
if err = yc.readConfigFile(); err != nil {
return
}

var filteredYarnArgs []string
yc.threads, filteredYarnArgs, yc.buildConfiguration, err = extractYarnOptionsFromArgs(yc.yarnArgs)
if err != nil {
return err
return
}

if err = yc.preparePrerequisites(); err != nil {
return
}

var missingDepsChan chan string
Expand Down Expand Up @@ -139,7 +262,6 @@ func (yc *YarnCommand) Run() (err error) {
return
}

log.Info("Yarn finished successfully.")
return
}

Expand All @@ -164,31 +286,43 @@ func (yc *YarnCommand) validateSupportedCommand() error {
return errorutils.CheckErrorf("The command 'jfrog rt yarn npm %s' is not supported.", npmCommand)
}
}
// validate 'yarn set version *' command
err := validateSupportedVersion(arg, yc.yarnArgs, index)
if err != nil {
return err
if arg == "set" && len(yc.yarnArgs) > index+2 && yc.yarnArgs[index+1] == "version" {
if err := validateSetVersion(yc.yarnArgs[index+2]); err != nil {
Comment thread
bhanurp marked this conversation as resolved.
return err
}
}
}
return nil
}

// validateSupportedVersion checks if the version to be set is supported.
// currently version 4 is not supported.
func validateSupportedVersion(arg string, yarnArgs []string, index int) error {
if arg == "set" && len(yarnArgs) > index {
setCommand := yarnArgs[index+1]
if setCommand == "version" && len(yarnArgs) > index+2 {
versionCommand := yarnArgs[index+2]
err := yarn.IsVersionSupported(versionCommand)
if err != nil {
return err
}
}
// validateSetVersion checks that the requested yarn version in 'yarn set version X.Y.Z'
// does not exceed the maximum supported major version.
func validateSetVersion(setVersion string) error {
v := version.NewVersion(setVersion)
if v.AtLeast(nextMajorYarnVersion) {
return errorutils.CheckErrorf(
"Yarn version %s is not supported. The maximum supported major version is %s.",
setVersion, maxSupportedYarnVersion)
}
return nil
}

// skipVersionCheck returns true for commands that should not trigger a yarn version
// compatibility check (e.g. 'yarn set version' or 'yarn --version').
func skipVersionCheck(args []string) bool {
Comment thread
fluxxBot marked this conversation as resolved.
for i, arg := range args {
if arg == "--version" || arg == "-v" {
return true
}
if arg == "set" && len(args) > i+1 && args[i+1] == "version" {
return true
}
}
return false
}



func (yc *YarnCommand) readConfigFile() error {
log.Debug("Preparing to read the config file", yc.configFilePath)
vConfig, err := project.ReadConfigFile(yc.configFilePath, project.YAML)
Expand Down Expand Up @@ -246,6 +380,11 @@ func (yc *YarnCommand) preparePrerequisites() error {
yc.buildInfoModule.SetName(yc.buildConfiguration.GetModule())
}

// In native mode, the user handles auth via their own .yarnrc.yml — skip Artifactory auth setup.
if yc.useNative {
return nil
}

yc.registry, yc.npmAuthIdent, yc.npmAuthToken, err = GetYarnAuthDetails(yc.serverDetails, yc.repo)
return err
}
Expand Down Expand Up @@ -298,33 +437,6 @@ func GetYarnAuthDetails(server *config.ServerDetails, repo string) (registry, np
return
}

func verifyYarnVersion(executablePath string, filteredYarnArgs []string) error {
if skipVersionCheck(filteredYarnArgs) {

log.Debug("Skipping yarn version verification")
return nil
}
err := yarn.IsInstalledYarnVersionSupported(executablePath)
log.Debug("Yarn version verified")
if err != nil {
return err
}
log.Debug("Successfully verified yarn version")
return nil
}

func skipVersionCheck(filteredYarnArgs []string) bool {
// Allow 'yarn set version' command - (this will help to downgrade and upgrade yarn version)
if len(filteredYarnArgs) >= 2 && filteredYarnArgs[0] == "set" && filteredYarnArgs[1] == "version" {
return true
}

// Allow '--version' to check current version
if len(filteredYarnArgs) >= 1 && filteredYarnArgs[0] == "--version" {
return true
}
return false
}

func setArtifactoryAuth(server *config.ServerDetails) (auth.ServiceDetails, error) {
authArtDetails, err := server.CreateArtAuthConfig()
Expand Down Expand Up @@ -384,8 +496,8 @@ func updateScopeRegistries(execPath, registry, npmAuthIdent, npmAuthToken string
if err != nil {
return err
}
// If npmScopesStr is "undefined" it means that the npmScopes configuration does not exist in case using yarn version 4.
if npmScopesStr == "undefined" {
// Empty string means the npmScopes configuration is not set (normalised from yarn v4's "undefined" by ConfigGet).
if npmScopesStr == "" {
return nil
}
npmScopesMap := make(map[string]yarnNpmScope)
Expand Down Expand Up @@ -507,7 +619,7 @@ func getDependencyInfo(name, ver string, previousBuildDependencies map[string]*e
return
}

func extractYarnOptionsFromArgs(args []string) (threads int, detailedSummary, xrayScan bool, scanOutputFormat format.OutputFormat, cleanArgs []string, buildConfig *buildUtils.BuildConfiguration, err error) {
func extractYarnOptionsFromArgs(args []string) (threads int, cleanArgs []string, buildConfig *buildUtils.BuildConfiguration, err error) {
threads = 3
// Extract threads information from the args.
flagIndex, valueIndex, numOfThreads, err := coreutils.FindFlag("--threads", args)
Expand All @@ -522,7 +634,7 @@ func extractYarnOptionsFromArgs(args []string) (threads int, detailedSummary, xr
return
}
}
detailedSummary, xrayScan, scanOutputFormat, cleanArgs, buildConfig, err = commandUtils.ExtractNpmOptionsFromArgs(args)
_, _, _, cleanArgs, buildConfig, err = commandUtils.ExtractNpmOptionsFromArgs(args)
return
}

Expand All @@ -536,19 +648,45 @@ func printMissingDependencies(missingDependencies []string) {
}

func createCollectChecksumsFunc(previousBuildDependencies map[string]*entities.Dependency, servicesManager artifactory.ArtifactoryServicesManager, missingDepsChan chan string) func(dependency *entities.Dependency) (bool, error) {
type cacheEntry struct {
checksum entities.Checksum
fileType string
missing bool
}
// Process-wide cache shared across all parallel workers for this build run.
// Prevents duplicate AQL calls for the same dep id (common in monorepos where
// multiple workspace modules share transitive dependencies).
var checksumCache sync.Map // id -> cacheEntry

return func(dependency *entities.Dependency) (bool, error) {
if v, ok := checksumCache.Load(dependency.Id); ok {
e, ok := v.(cacheEntry)
if !ok {
return false, nil
}
if e.missing {
// Already warned on first miss — skip channel to avoid duplicate warn entries.
return false, nil
}
dependency.Checksum = e.checksum
dependency.Type = e.fileType
return true, nil
}

splitDepId := strings.SplitN(dependency.Id, ":", 2)
name := splitDepId[0]
ver := splitDepId[1]

// Get dependency info.
checksum, fileType, err := getDependencyInfo(name, ver, previousBuildDependencies, servicesManager)
if err != nil || checksum.IsEmpty() {
checksumCache.Store(dependency.Id, cacheEntry{missing: true})
missingDepsChan <- dependency.Id
return false, err
}

// Update dependency.
// Cache result and update dependency.
checksumCache.Store(dependency.Id, cacheEntry{checksum: checksum, fileType: fileType})
dependency.Type = fileType
dependency.Checksum = checksum
return true, nil
Expand Down
Loading
Loading