github.com/jfrog/jfrog-cli-core/v2@v2.52.0/artifactory/commands/npm/npmcommand.go (about)

     1  package npm
     2  
     3  import (
     4  	"bufio"
     5  	"errors"
     6  	"fmt"
     7  	"github.com/jfrog/build-info-go/build"
     8  	biUtils "github.com/jfrog/build-info-go/build/utils"
     9  	"github.com/jfrog/gofrog/version"
    10  	commandUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils"
    11  	"github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/npm"
    12  	buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build"
    13  	"github.com/jfrog/jfrog-cli-core/v2/common/project"
    14  	"github.com/jfrog/jfrog-cli-core/v2/utils/config"
    15  	"github.com/jfrog/jfrog-cli-core/v2/utils/coreutils"
    16  	"github.com/jfrog/jfrog-cli-core/v2/utils/ioutils"
    17  	"github.com/jfrog/jfrog-client-go/auth"
    18  	"github.com/jfrog/jfrog-client-go/utils/errorutils"
    19  	"github.com/jfrog/jfrog-client-go/utils/log"
    20  	"github.com/spf13/viper"
    21  	"os"
    22  	"path/filepath"
    23  	"strconv"
    24  	"strings"
    25  )
    26  
    27  const (
    28  	npmrcFileName          = ".npmrc"
    29  	npmrcBackupFileName    = "jfrog.npmrc.backup"
    30  	minSupportedNpmVersion = "5.4.0"
    31  )
    32  
    33  type NpmCommand struct {
    34  	CommonArgs
    35  	cmdName        string
    36  	jsonOutput     bool
    37  	executablePath string
    38  	// Function to be called to restore the user's old npmrc and delete the one we created.
    39  	restoreNpmrcFunc func() error
    40  	workingDirectory string
    41  	// Npm registry as exposed by Artifactory.
    42  	registry string
    43  	// Npm token generated by Artifactory using the user's provided credentials.
    44  	npmAuth             string
    45  	authArtDetails      auth.ServiceDetails
    46  	npmVersion          *version.Version
    47  	internalCommandName string
    48  	configFilePath      string
    49  	collectBuildInfo    bool
    50  	buildInfoModule     *build.NpmModule
    51  }
    52  
    53  func NewNpmCommand(cmdName string, collectBuildInfo bool) *NpmCommand {
    54  	return &NpmCommand{
    55  		cmdName:          cmdName,
    56  		collectBuildInfo: collectBuildInfo,
    57  	}
    58  }
    59  
    60  func NewNpmInstallCommand() *NpmCommand {
    61  	return &NpmCommand{cmdName: "install", internalCommandName: "rt_npm_install"}
    62  }
    63  
    64  func NewNpmCiCommand() *NpmCommand {
    65  	return &NpmCommand{cmdName: "ci", internalCommandName: "rt_npm_ci"}
    66  }
    67  
    68  func (nc *NpmCommand) CommandName() string {
    69  	return nc.internalCommandName
    70  }
    71  
    72  func (nc *NpmCommand) SetConfigFilePath(configFilePath string) *NpmCommand {
    73  	nc.configFilePath = configFilePath
    74  	return nc
    75  }
    76  
    77  func (nc *NpmCommand) SetArgs(args []string) *NpmCommand {
    78  	nc.npmArgs = args
    79  	return nc
    80  }
    81  
    82  func (nc *NpmCommand) SetRepoConfig(conf *project.RepositoryConfig) *NpmCommand {
    83  	serverDetails, _ := conf.ServerDetails()
    84  	nc.SetRepo(conf.TargetRepo()).SetServerDetails(serverDetails)
    85  	return nc
    86  }
    87  
    88  func (nc *NpmCommand) SetServerDetails(serverDetails *config.ServerDetails) *NpmCommand {
    89  	nc.serverDetails = serverDetails
    90  	return nc
    91  }
    92  
    93  func (nc *NpmCommand) SetRepo(repo string) *NpmCommand {
    94  	nc.repo = repo
    95  	return nc
    96  }
    97  
    98  func (nc *NpmCommand) Init() error {
    99  	// Read config file.
   100  	log.Debug("Preparing to read the config file", nc.configFilePath)
   101  	vConfig, err := project.ReadConfigFile(nc.configFilePath, project.YAML)
   102  	if err != nil {
   103  		return err
   104  	}
   105  
   106  	repoConfig, err := nc.getRepoConfig(vConfig)
   107  	if err != nil {
   108  		return err
   109  	}
   110  	_, _, _, filteredNpmArgs, buildConfiguration, err := commandUtils.ExtractNpmOptionsFromArgs(nc.npmArgs)
   111  	if err != nil {
   112  		return err
   113  	}
   114  	nc.SetRepoConfig(repoConfig).SetArgs(filteredNpmArgs).SetBuildConfiguration(buildConfiguration)
   115  	return nil
   116  }
   117  
   118  // Get the repository configuration from the config file.
   119  // Use the resolver prefix for all commands except for 'dist-tag' which use the deployer prefix.
   120  func (nc *NpmCommand) getRepoConfig(vConfig *viper.Viper) (repoConfig *project.RepositoryConfig, err error) {
   121  	prefix := project.ProjectConfigResolverPrefix
   122  	// Aliases accepted by npm.
   123  	if nc.cmdName == "dist-tag" || nc.cmdName == "dist-tags" {
   124  		prefix = project.ProjectConfigDeployerPrefix
   125  	}
   126  	return project.GetRepoConfigByPrefix(nc.configFilePath, prefix, vConfig)
   127  }
   128  
   129  func (nc *NpmCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *NpmCommand {
   130  	nc.buildConfiguration = buildConfiguration
   131  	return nc
   132  }
   133  
   134  func (nc *NpmCommand) ServerDetails() (*config.ServerDetails, error) {
   135  	return nc.serverDetails, nil
   136  }
   137  
   138  func (nc *NpmCommand) RestoreNpmrcFunc() func() error {
   139  	return nc.restoreNpmrcFunc
   140  }
   141  
   142  func (nc *NpmCommand) PreparePrerequisites(repo string) error {
   143  	log.Debug("Preparing prerequisites...")
   144  	var err error
   145  	nc.npmVersion, nc.executablePath, err = biUtils.GetNpmVersionAndExecPath(log.Logger)
   146  	if err != nil {
   147  		return err
   148  	}
   149  	if nc.npmVersion.Compare(minSupportedNpmVersion) > 0 {
   150  		return errorutils.CheckErrorf(
   151  			"JFrog CLI npm %s command requires npm client version %s or higher. The Current version is: %s", nc.cmdName, minSupportedNpmVersion, nc.npmVersion.GetVersion())
   152  	}
   153  
   154  	if err = nc.setJsonOutput(); err != nil {
   155  		return err
   156  	}
   157  
   158  	nc.workingDirectory, err = coreutils.GetWorkingDirectory()
   159  	if err != nil {
   160  		return err
   161  	}
   162  	log.Debug("Working directory set to:", nc.workingDirectory)
   163  	if err = nc.setArtifactoryAuth(); err != nil {
   164  		return err
   165  	}
   166  
   167  	nc.npmAuth, nc.registry, err = commandUtils.GetArtifactoryNpmRepoDetails(repo, &nc.authArtDetails)
   168  	if err != nil {
   169  		return err
   170  	}
   171  
   172  	return nc.setRestoreNpmrcFunc()
   173  }
   174  
   175  func (nc *NpmCommand) setRestoreNpmrcFunc() error {
   176  	restoreNpmrcFunc, err := ioutils.BackupFile(filepath.Join(nc.workingDirectory, npmrcFileName), npmrcBackupFileName)
   177  	if err != nil {
   178  		return err
   179  	}
   180  	nc.restoreNpmrcFunc = func() error {
   181  		if unsetEnvErr := os.Unsetenv(npmConfigAuthEnv); unsetEnvErr != nil {
   182  			return unsetEnvErr
   183  		}
   184  		return restoreNpmrcFunc()
   185  	}
   186  	return nil
   187  }
   188  
   189  func (nc *NpmCommand) setArtifactoryAuth() error {
   190  	authArtDetails, err := nc.serverDetails.CreateArtAuthConfig()
   191  	if err != nil {
   192  		return err
   193  	}
   194  	if authArtDetails.GetSshAuthHeaders() != nil {
   195  		return errorutils.CheckErrorf("SSH authentication is not supported in this command")
   196  	}
   197  	nc.authArtDetails = authArtDetails
   198  	return nil
   199  }
   200  
   201  func (nc *NpmCommand) setJsonOutput() error {
   202  	jsonOutput, err := npm.ConfigGet(nc.npmArgs, "json", nc.executablePath)
   203  	if err != nil {
   204  		return err
   205  	}
   206  
   207  	// In case of --json=<not boolean>, the value of json is set to 'true', but the result from the command is not 'true'
   208  	nc.jsonOutput = jsonOutput != "false"
   209  	return nil
   210  }
   211  
   212  func (nc *NpmCommand) processConfigLine(configLine string) (filteredLine string, err error) {
   213  	splitOption := strings.SplitN(configLine, "=", 2)
   214  	key := strings.TrimSpace(splitOption[0])
   215  	validLine := len(splitOption) == 2 && isValidKey(key)
   216  	if !validLine {
   217  		if strings.HasPrefix(splitOption[0], "@") {
   218  			// Override scoped registries (@scope = xyz)
   219  			return fmt.Sprintf("%s = %s\n", splitOption[0], nc.registry), nil
   220  		}
   221  		return
   222  	}
   223  	value := strings.TrimSpace(splitOption[1])
   224  	if key == "_auth" {
   225  		return "", nc.setNpmConfigAuthEnv(value)
   226  	}
   227  	if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
   228  		return addArrayConfigs(key, value), nil
   229  	}
   230  
   231  	return fmt.Sprintf("%s\n", configLine), err
   232  }
   233  
   234  func (nc *NpmCommand) setNpmConfigAuthEnv(value string) error {
   235  	// Check if the npm version is bigger or equal to 9.3.1
   236  	if nc.npmVersion.Compare(npmVersionForLegacyEnv) <= 0 {
   237  		// Get registry name without the protocol name but including the '//'
   238  		registryWithoutProtocolName := nc.registry[strings.Index(nc.registry, "://")+1:]
   239  		// Set "npm_config_//<registry-url>:_auth" environment variable to allow authentication with Artifactory
   240  		scopedRegistryEnv := fmt.Sprintf(npmConfigAuthEnv, registryWithoutProtocolName)
   241  		return os.Setenv(scopedRegistryEnv, value)
   242  	}
   243  	// Set "npm_config__auth" environment variable to allow authentication with Artifactory when running post-install scripts on subdirectories.
   244  	// For Legacy NPM version < 9.3.1
   245  	return os.Setenv(npmLegacyConfigAuthEnv, value)
   246  }
   247  
   248  func (nc *NpmCommand) prepareConfigData(data []byte) ([]byte, error) {
   249  	var filteredConf []string
   250  	configString := string(data) + "\n" + nc.npmAuth
   251  	scanner := bufio.NewScanner(strings.NewReader(configString))
   252  	for scanner.Scan() {
   253  		currOption := scanner.Text()
   254  		if currOption == "" {
   255  			continue
   256  		}
   257  		filteredLine, err := nc.processConfigLine(currOption)
   258  		if err != nil {
   259  			return nil, errorutils.CheckError(err)
   260  		}
   261  		if filteredLine != "" {
   262  			filteredConf = append(filteredConf, filteredLine)
   263  		}
   264  	}
   265  	if err := scanner.Err(); err != nil {
   266  		return nil, errorutils.CheckError(err)
   267  	}
   268  
   269  	filteredConf = append(filteredConf, "json = ", strconv.FormatBool(nc.jsonOutput), "\n")
   270  	filteredConf = append(filteredConf, "registry = ", nc.registry, "\n")
   271  	return []byte(strings.Join(filteredConf, "")), nil
   272  }
   273  
   274  func (nc *NpmCommand) CreateTempNpmrc() error {
   275  	data, err := npm.GetConfigList(nc.npmArgs, nc.executablePath)
   276  	if err != nil {
   277  		return err
   278  	}
   279  	configData, err := nc.prepareConfigData(data)
   280  	if err != nil {
   281  		return errorutils.CheckError(err)
   282  	}
   283  
   284  	if err = removeNpmrcIfExists(nc.workingDirectory); err != nil {
   285  		return err
   286  	}
   287  	log.Debug("Creating temporary .npmrc file.")
   288  	return errorutils.CheckError(os.WriteFile(filepath.Join(nc.workingDirectory, npmrcFileName), configData, 0755))
   289  }
   290  
   291  func (nc *NpmCommand) Run() (err error) {
   292  	if err = nc.PreparePrerequisites(nc.repo); err != nil {
   293  		return
   294  	}
   295  	defer func() {
   296  		err = errors.Join(err, nc.restoreNpmrcFunc())
   297  	}()
   298  	if err = nc.CreateTempNpmrc(); err != nil {
   299  		return
   300  	}
   301  
   302  	if err = nc.prepareBuildInfoModule(); err != nil {
   303  		return
   304  	}
   305  
   306  	err = nc.collectDependencies()
   307  	return
   308  }
   309  
   310  func (nc *NpmCommand) prepareBuildInfoModule() error {
   311  	var err error
   312  	if nc.collectBuildInfo {
   313  		nc.collectBuildInfo, err = nc.buildConfiguration.IsCollectBuildInfo()
   314  		if err != nil {
   315  			return err
   316  		}
   317  	}
   318  	// Build-info should not be created when installing a single package (npm install <package name>).
   319  	if nc.collectBuildInfo && len(filterFlags(nc.npmArgs)) > 0 {
   320  		log.Info("Build-info dependencies collection is not supported for installations of single packages. Build-info creation is skipped.")
   321  		nc.collectBuildInfo = false
   322  	}
   323  	buildName, err := nc.buildConfiguration.GetBuildName()
   324  	if err != nil {
   325  		return err
   326  	}
   327  	buildNumber, err := nc.buildConfiguration.GetBuildNumber()
   328  	if err != nil {
   329  		return err
   330  	}
   331  	buildInfoService := buildUtils.CreateBuildInfoService()
   332  	npmBuild, err := buildInfoService.GetOrCreateBuildWithProject(buildName, buildNumber, nc.buildConfiguration.GetProject())
   333  	if err != nil {
   334  		return errorutils.CheckError(err)
   335  	}
   336  	nc.buildInfoModule, err = npmBuild.AddNpmModule(nc.workingDirectory)
   337  	if err != nil {
   338  		return errorutils.CheckError(err)
   339  	}
   340  	nc.buildInfoModule.SetCollectBuildInfo(nc.collectBuildInfo)
   341  	if nc.buildConfiguration.GetModule() != "" {
   342  		nc.buildInfoModule.SetName(nc.buildConfiguration.GetModule())
   343  	}
   344  	return nil
   345  }
   346  
   347  func (nc *NpmCommand) collectDependencies() error {
   348  	nc.buildInfoModule.SetNpmArgs(append([]string{nc.cmdName}, nc.npmArgs...))
   349  	return errorutils.CheckError(nc.buildInfoModule.Build())
   350  }
   351  
   352  // Gets a config with value which is an array
   353  func addArrayConfigs(key, arrayValue string) string {
   354  	if arrayValue == "[]" {
   355  		return ""
   356  	}
   357  
   358  	values := strings.TrimPrefix(strings.TrimSuffix(arrayValue, "]"), "[")
   359  	valuesSlice := strings.Split(values, ",")
   360  	var configArrayValues strings.Builder
   361  	for _, val := range valuesSlice {
   362  		configArrayValues.WriteString(fmt.Sprintf("%s[] = %s\n", key, val))
   363  	}
   364  
   365  	return configArrayValues.String()
   366  }
   367  
   368  func removeNpmrcIfExists(workingDirectory string) error {
   369  	if _, err := os.Stat(filepath.Join(workingDirectory, npmrcFileName)); err != nil {
   370  		// The file does not exist, nothing to do.
   371  		if os.IsNotExist(err) {
   372  			return nil
   373  		}
   374  		return errorutils.CheckError(err)
   375  	}
   376  
   377  	log.Debug("Removing existing .npmrc file")
   378  	return errorutils.CheckError(os.Remove(filepath.Join(workingDirectory, npmrcFileName)))
   379  }
   380  
   381  // To avoid writing configurations that are used by us
   382  func isValidKey(key string) bool {
   383  	return !strings.HasPrefix(key, "//") &&
   384  		!strings.HasPrefix(key, ";") && // Comments
   385  		!strings.HasPrefix(key, "@") && // Scoped configurations
   386  		key != "registry" &&
   387  		key != "metrics-registry" &&
   388  		key != "json" // Handled separately because 'npm c ls' should run with json=false
   389  }
   390  
   391  func filterFlags(splitArgs []string) []string {
   392  	var filteredArgs []string
   393  	for _, arg := range splitArgs {
   394  		if !strings.HasPrefix(arg, "-") {
   395  			filteredArgs = append(filteredArgs, arg)
   396  		}
   397  	}
   398  	return filteredArgs
   399  }
   400  
   401  func (nc *NpmCommand) GetRepo() string {
   402  	return nc.repo
   403  }
   404  
   405  // Creates an .npmrc file in the project's directory in order to configure the provided Artifactory server as a resolution server
   406  func SetArtifactoryAsResolutionServer(serverDetails *config.ServerDetails, depsRepo string) (clearResolutionServerFunc func() error, err error) {
   407  	npmCmd := NewNpmInstallCommand().SetServerDetails(serverDetails)
   408  	if err = npmCmd.PreparePrerequisites(depsRepo); err != nil {
   409  		return
   410  	}
   411  	if err = npmCmd.CreateTempNpmrc(); err != nil {
   412  		return
   413  	}
   414  	clearResolutionServerFunc = npmCmd.RestoreNpmrcFunc()
   415  	log.Info(fmt.Sprintf("Resolving dependencies from '%s' from repo '%s'", serverDetails.Url, depsRepo))
   416  	return
   417  }