diff --git a/artifactory/commands/npm/npmcommand.go b/artifactory/commands/npm/npmcommand.go index 853352d2..14de553b 100644 --- a/artifactory/commands/npm/npmcommand.go +++ b/artifactory/commands/npm/npmcommand.go @@ -249,7 +249,11 @@ func (nc *NpmCommand) setNpmConfigAuthEnv(value, authKey string) error { if nc.isNpmVersionSupportsScopedAuthEnv() { // Get registry name without the protocol name but including the '//' registryWithoutProtocolName := nc.registry[strings.Index(nc.registry, "://")+1:] - // Set "npm_config_//:_auth" environment variable to allow authentication with Artifactory + // Ensure registry ends with / (required for scoped auth) + if !strings.HasSuffix(registryWithoutProtocolName, "/") { + registryWithoutProtocolName += "/" + } + // Set "npm_config_///:_auth" environment variable to allow authentication with Artifactory scopedRegistryEnv := fmt.Sprintf(npmConfigAuthEnv, registryWithoutProtocolName, authKey) return os.Setenv(scopedRegistryEnv, value) } diff --git a/artifactory/commands/npm/npmcommand_test.go b/artifactory/commands/npm/npmcommand_test.go index 62f6bfaa..763a66e4 100644 --- a/artifactory/commands/npm/npmcommand_test.go +++ b/artifactory/commands/npm/npmcommand_test.go @@ -74,8 +74,8 @@ func TestPrepareConfigData(t *testing.T) { } // Assert that NPM_CONFIG__AUTH environment variable was set - assert.Equal(t, getTestCredentialValue(), os.Getenv(fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry", utils.NpmConfigAuthKey))) - testsUtils.UnSetEnvAndAssert(t, fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry", utils.NpmConfigAuthKey)) + assert.Equal(t, getTestCredentialValue(), os.Getenv(fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry/", utils.NpmConfigAuthKey))) + testsUtils.UnSetEnvAndAssert(t, fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry/", utils.NpmConfigAuthKey)) } func TestSetNpmConfigAuthEnv(t *testing.T) { @@ -93,7 +93,7 @@ func TestSetNpmConfigAuthEnv(t *testing.T) { }, authKey: utils.NpmConfigAuthKey, value: "some_auth_token", - expectedEnv: "npm_config_//registry.example.com:_auth", + expectedEnv: "npm_config_//registry.example.com/:_auth", }, { name: "set scoped registry authToken env", @@ -102,7 +102,7 @@ func TestSetNpmConfigAuthEnv(t *testing.T) { }, authKey: utils.NpmConfigAuthTokenKey, value: "some_auth_token", - expectedEnv: "npm_config_//registry.example.com:_authToken", + expectedEnv: "npm_config_//registry.example.com/:_authToken", }, { name: "set legacy auth env", diff --git a/artifactory/commands/pnpm/artifactoryinstall.go b/artifactory/commands/pnpm/artifactoryinstall.go new file mode 100644 index 00000000..eab65e02 --- /dev/null +++ b/artifactory/commands/pnpm/artifactoryinstall.go @@ -0,0 +1,36 @@ +package pnpm + +import "github.com/jfrog/jfrog-client-go/utils/log" + +type pnpmRtInstall struct { + *PnpmCommand +} + +func (pri *pnpmRtInstall) PrepareInstallPrerequisites(repo string) (err error) { + log.Debug("Executing pnpm install command using jfrog RT on repository: ", repo) + if err = pri.setArtifactoryAuth(); err != nil { + return err + } + + if err = pri.setNpmAuthRegistry(repo); err != nil { + return err + } + + return pri.setRestoreNpmrcFunc() +} + +func (pri *pnpmRtInstall) Run() (err error) { + if err = pri.CreateTempNpmrc(); err != nil { + return + } + if err = pri.prepareBuildInfoModule(); err != nil { + return + } + err = pri.collectDependencies() + return +} + +func (pri *pnpmRtInstall) RestoreNpmrc() (err error) { + // Restore the npmrc file, since we are using our own npmrc + return pri.restoreNpmrcFunc() +} diff --git a/artifactory/commands/pnpm/artifactoryupload.go b/artifactory/commands/pnpm/artifactoryupload.go new file mode 100644 index 00000000..62178b0a --- /dev/null +++ b/artifactory/commands/pnpm/artifactoryupload.go @@ -0,0 +1,156 @@ +package pnpm + +import ( + "errors" + "fmt" + + buildinfo "github.com/jfrog/build-info-go/entities" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/utils/civcs" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/content" +) + +type pnpmRtUpload struct { + *PnpmPublishCommand +} + +func (pru *pnpmRtUpload) upload() (err error) { + for _, packedFilePath := range pru.packedFilePaths { + if err = pru.readPackageInfoFromTarball(packedFilePath); err != nil { + return + } + target := fmt.Sprintf("%s/%s", pru.repo, pru.packageInfo.GetDeployPath()) + + // If requested, perform a Xray binary scan before deployment. If a FailBuildError is returned, skip the deployment. + if pru.xrayScan { + if err = performXrayScan(packedFilePath, pru.repo, pru.serverDetails, pru.scanOutputFormat); err != nil { + return + } + } + err = errors.Join(err, pru.doDeploy(target, pru.serverDetails, packedFilePath)) + } + return +} + +func (pru *pnpmRtUpload) getBuildArtifacts() []buildinfo.Artifact { + return ConvertArtifactsDetailsToBuildInfoArtifacts(pru.artifactsDetailsReader, specutils.ConvertArtifactsDetailsToBuildInfoArtifacts) +} + +func (pru *pnpmRtUpload) doDeploy(target string, artDetails *config.ServerDetails, packedFilePath string) error { + servicesManager, err := utils.CreateServiceManager(artDetails, -1, 0, false) + if err != nil { + return err + } + up := services.NewUploadParams() + up.CommonParams = &specutils.CommonParams{Pattern: packedFilePath, Target: target} + if err = pru.addDistTagIfSet(up.CommonParams); err != nil { + return err + } + // Add CI VCS properties if in CI environment + if err = pru.addCIVcsProps(up.CommonParams); err != nil { + return err + } + var totalFailed int + if pru.collectBuildInfo || pru.detailedSummary { + if pru.collectBuildInfo { + up.BuildProps, err = pru.getBuildPropsForArtifact() + if err != nil { + return err + } + } + summary, err := servicesManager.UploadFilesWithSummary(artifactory.UploadServiceOptions{}, up) + if err != nil { + return err + } + totalFailed = summary.TotalFailed + if pru.collectBuildInfo { + pru.artifactsDetailsReader = append(pru.artifactsDetailsReader, summary.ArtifactsDetailsReader) + } else { + err = summary.ArtifactsDetailsReader.Close() + if err != nil { + return err + } + } + if pru.detailedSummary { + if err = pru.setDetailedSummary(summary); err != nil { + return err + } + } else { + if err = summary.TransferDetailsReader.Close(); err != nil { + return err + } + } + } else { + _, totalFailed, err = servicesManager.UploadFiles(artifactory.UploadServiceOptions{}, up) + if err != nil { + return err + } + } + + // We are deploying only one Artifact which have to be deployed, in case of failure we should fail + if totalFailed > 0 { + return errorutils.CheckErrorf("Failed to upload the pnpm package to Artifactory. See Artifactory logs for more details.") + } + return nil +} + +func (pru *pnpmRtUpload) addDistTagIfSet(params *specutils.CommonParams) error { + if pru.distTag == "" { + return nil + } + props, err := specutils.ParseProperties(DistTagPropKey + "=" + pru.distTag) + if err != nil { + return err + } + params.TargetProps = props + return nil +} + +// addCIVcsProps adds CI VCS properties to the upload params if in CI environment. +func (pru *pnpmRtUpload) addCIVcsProps(params *specutils.CommonParams) error { + ciProps := civcs.GetCIVcsPropsString() + if ciProps == "" { + return nil + } + if params.TargetProps == nil { + props, err := specutils.ParseProperties(ciProps) + if err != nil { + return err + } + params.TargetProps = props + } else { + // Merge with existing properties + if err := params.TargetProps.ParseAndAddProperties(ciProps); err != nil { + return err + } + } + return nil +} + +func (pru *pnpmRtUpload) appendReader(summary *specutils.OperationSummary) error { + readersSlice := []*content.ContentReader{pru.result.Reader(), summary.TransferDetailsReader} + reader, err := content.MergeReaders(readersSlice, content.DefaultKey) + if err != nil { + return err + } + pru.result.SetReader(reader) + return nil +} + +func (pru *pnpmRtUpload) setDetailedSummary(summary *specutils.OperationSummary) (err error) { + pru.result.SetFailCount(pru.result.FailCount() + summary.TotalFailed) + pru.result.SetSuccessCount(pru.result.SuccessCount() + summary.TotalSucceeded) + if pru.result.Reader() == nil { + pru.result.SetReader(summary.TransferDetailsReader) + } else { + if err = pru.appendReader(summary); err != nil { + return + } + } + return +} diff --git a/artifactory/commands/pnpm/common.go b/artifactory/commands/pnpm/common.go new file mode 100644 index 00000000..722a39f9 --- /dev/null +++ b/artifactory/commands/pnpm/common.go @@ -0,0 +1,43 @@ +package pnpm + +import ( + "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" +) + +type CommonArgs struct { + repo string + buildConfiguration *build.BuildConfiguration + pnpmArgs []string + serverDetails *config.ServerDetails + useNative bool +} + +func (ca *CommonArgs) SetServerDetails(serverDetails *config.ServerDetails) *CommonArgs { + ca.serverDetails = serverDetails + return ca +} + +func (ca *CommonArgs) SetPnpmArgs(pnpmArgs []string) *CommonArgs { + ca.pnpmArgs = pnpmArgs + return ca +} + +func (ca *CommonArgs) SetBuildConfiguration(buildConfiguration *build.BuildConfiguration) *CommonArgs { + ca.buildConfiguration = buildConfiguration + return ca +} + +func (ca *CommonArgs) SetRepo(repo string) *CommonArgs { + ca.repo = repo + return ca +} + +func (ca *CommonArgs) UseNative() bool { + return ca.useNative +} + +func (ca *CommonArgs) SetUseNative(useNpmRc bool) *CommonArgs { + ca.useNative = useNpmRc + return ca +} diff --git a/artifactory/commands/pnpm/installstrategy.go b/artifactory/commands/pnpm/installstrategy.go new file mode 100644 index 00000000..a8e0c45c --- /dev/null +++ b/artifactory/commands/pnpm/installstrategy.go @@ -0,0 +1,41 @@ +package pnpm + +import "github.com/jfrog/jfrog-client-go/utils/log" + +type Installer interface { + PrepareInstallPrerequisites(repo string) error + Run() error + RestoreNpmrc() error +} + +type PnpmInstallStrategy struct { + strategy Installer + strategyName string +} + +// Get pnpm implementation +func NewPnpmInstallStrategy(useNativeClient bool, pnpmCommand *PnpmCommand) *PnpmInstallStrategy { + ppi := PnpmInstallStrategy{} + if useNativeClient { + ppi.strategy = &pnpmInstall{pnpmCommand} + ppi.strategyName = "native" + } else { + ppi.strategy = &pnpmRtInstall{pnpmCommand} + ppi.strategyName = "artifactory" + } + return &ppi +} + +func (ppi *PnpmInstallStrategy) PrepareInstallPrerequisites(repo string) error { + log.Debug("Using strategy for preparing install prerequisites: ", ppi.strategyName) + return ppi.strategy.PrepareInstallPrerequisites(repo) +} + +func (ppi *PnpmInstallStrategy) Install() error { + log.Debug("Using strategy for pnpm install: ", ppi.strategyName) + return ppi.strategy.Run() +} + +func (ppi *PnpmInstallStrategy) RestoreNpmrc() error { + return ppi.strategy.RestoreNpmrc() +} diff --git a/artifactory/commands/pnpm/pnpmcommand.go b/artifactory/commands/pnpm/pnpmcommand.go new file mode 100644 index 00000000..f8c9932c --- /dev/null +++ b/artifactory/commands/pnpm/pnpmcommand.go @@ -0,0 +1,506 @@ +package pnpm + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/jfrog/build-info-go/build" + biUtils "github.com/jfrog/build-info-go/build/utils" + gofrogcmd "github.com/jfrog/gofrog/io" + "github.com/jfrog/gofrog/version" + commandUtils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "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/jfrog-client-go/auth" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/spf13/viper" +) + +const ( + npmrcFileName = ".npmrc" + npmrcBackupFileName = "jfrog.npmrc.backup" + minSupportedPnpmVersion = "6.0.0" + + // Scoped authentication env var that sets the _auth or _authToken npm config variables. + // pnpm uses the same authentication mechanism as npm. + npmConfigAuthEnv = "npm_config_%s:%s" + pnpmVersionSupportingScopedAuthEnv = "7.0.0" + // Legacy un-scoped auth env vars doesn't support access tokens (with _authToken suffix). + npmLegacyConfigAuthEnv = "npm_config__auth" +) + +type PnpmCommand struct { + CommonArgs + cmdName string + jsonOutput bool + executablePath string + // Function to be called to restore the user's old npmrc and delete the one we created. + restoreNpmrcFunc func() error + workingDirectory string + // Npm registry as exposed by Artifactory. + registry string + // Npm token generated by Artifactory using the user's provided credentials. + npmAuth string + authArtDetails auth.ServiceDetails + pnpmVersion *version.Version + internalCommandName string + configFilePath string + collectBuildInfo bool + buildInfoModule *build.PnpmModule + installHandler *PnpmInstallStrategy +} + +func NewPnpmCommand(cmdName string, collectBuildInfo bool) *PnpmCommand { + return &PnpmCommand{ + cmdName: cmdName, + collectBuildInfo: collectBuildInfo, + internalCommandName: "rt_pnpm_" + cmdName, + } +} + +func NewPnpmInstallCommand() *PnpmCommand { + return &PnpmCommand{cmdName: "install", internalCommandName: "rt_pnpm_install"} +} + +func NewPnpmCiCommand() *PnpmCommand { + return &PnpmCommand{cmdName: "ci", internalCommandName: "rt_pnpm_ci"} +} + +func (pc *PnpmCommand) CommandName() string { + return pc.internalCommandName +} + +func (pc *PnpmCommand) SetConfigFilePath(configFilePath string) *PnpmCommand { + pc.configFilePath = configFilePath + return pc +} + +func (pc *PnpmCommand) SetArgs(args []string) *PnpmCommand { + pc.pnpmArgs = args + return pc +} + +func (pc *PnpmCommand) SetRepoConfig(conf *project.RepositoryConfig) *PnpmCommand { + serverDetails, _ := conf.ServerDetails() + pc.SetRepo(conf.TargetRepo()).SetServerDetails(serverDetails) + return pc +} + +func (pc *PnpmCommand) SetServerDetails(serverDetails *config.ServerDetails) *PnpmCommand { + pc.serverDetails = serverDetails + return pc +} + +func (pc *PnpmCommand) SetRepo(repo string) *PnpmCommand { + pc.repo = repo + return pc +} + +func (pc *PnpmCommand) Init() error { + // Read config file. + log.Debug("Preparing to read the config file", pc.configFilePath) + vConfig, err := project.ReadConfigFile(pc.configFilePath, project.YAML) + if err != nil { + return err + } + + repoConfig, err := pc.getRepoConfig(vConfig) + if err != nil { + return err + } + _, _, _, filteredPnpmArgs, buildConfiguration, err := commandUtils.ExtractNpmOptionsFromArgs(pc.pnpmArgs) + if err != nil { + return err + } + pc.SetRepoConfig(repoConfig).SetArgs(filteredPnpmArgs).SetBuildConfiguration(buildConfiguration) + return nil +} + +// Get the repository configuration from the config file. +// Use the resolver prefix for all commands except for 'dist-tag' which use the deployer prefix. +func (pc *PnpmCommand) getRepoConfig(vConfig *viper.Viper) (repoConfig *project.RepositoryConfig, err error) { + prefix := project.ProjectConfigResolverPrefix + // Aliases accepted by pnpm. + if pc.cmdName == "dist-tag" || pc.cmdName == "dist-tags" { + prefix = project.ProjectConfigDeployerPrefix + } + return project.GetRepoConfigByPrefix(pc.configFilePath, prefix, vConfig) +} + +func (pc *PnpmCommand) SetBuildConfiguration(buildConfiguration *buildUtils.BuildConfiguration) *PnpmCommand { + pc.buildConfiguration = buildConfiguration + return pc +} + +func (pc *PnpmCommand) ServerDetails() (*config.ServerDetails, error) { + return pc.serverDetails, nil +} + +func (pc *PnpmCommand) RestoreNpmrcFunc() func() error { + return pc.restoreNpmrcFunc +} + +func (pc *PnpmCommand) PreparePrerequisites(repo string) error { + log.Debug("Preparing prerequisites...") + var err error + pc.pnpmVersion, pc.executablePath, err = biUtils.GetPnpmVersionAndExecPath(log.Logger) + if err != nil { + return err + } + if pc.pnpmVersion.Compare(minSupportedPnpmVersion) > 0 { + return errorutils.CheckErrorf( + "JFrog CLI pnpm %s command requires pnpm client version %s or higher. The Current version is: %s", pc.cmdName, minSupportedPnpmVersion, pc.pnpmVersion.GetVersion()) + } + + if err = pc.setJsonOutput(); err != nil { + return err + } + + pc.workingDirectory, err = coreutils.GetWorkingDirectory() + if err != nil { + return err + } + log.Debug("Working directory set to:", pc.workingDirectory) + + _, useNative, err := coreutils.ExtractUseNativeFromArgs(pc.pnpmArgs) + if err != nil { + return err + } + pc.SetUseNative(useNative) + pc.installHandler = NewPnpmInstallStrategy(pc.UseNative(), pc) + + return pc.installHandler.PrepareInstallPrerequisites(repo) +} + +func (pc *PnpmCommand) setNpmAuthRegistry(repo string) (err error) { + pc.npmAuth, pc.registry, err = commandUtils.GetArtifactoryNpmRepoDetails(repo, pc.authArtDetails, !pc.isPnpmVersionSupportsScopedAuthEnv()) + return +} + +func (pc *PnpmCommand) setRestoreNpmrcFunc() error { + restoreNpmrcFunc, err := ioutils.BackupFile(filepath.Join(pc.workingDirectory, npmrcFileName), npmrcBackupFileName) + if err != nil { + return err + } + pc.restoreNpmrcFunc = func() error { + if unsetEnvErr := os.Unsetenv(npmConfigAuthEnv); unsetEnvErr != nil { + return unsetEnvErr + } + return restoreNpmrcFunc() + } + return nil +} + +func (pc *PnpmCommand) setArtifactoryAuth() error { + authArtDetails, err := pc.serverDetails.CreateArtAuthConfig() + if err != nil { + return err + } + if authArtDetails.GetSshAuthHeaders() != nil { + return errorutils.CheckErrorf("SSH authentication is not supported in this command") + } + pc.authArtDetails = authArtDetails + return nil +} + +func (pc *PnpmCommand) setJsonOutput() error { + jsonOutput, err := getPnpmConfigValue(pc.executablePath, "json") + if err != nil { + // pnpm may return error if config key doesn't exist, default to false + log.Debug("Failed to get json config from pnpm, defaulting to false:", err.Error()) + pc.jsonOutput = false + return nil + } + + // In case of --json=, the value of json is set to 'true', but the result from the command is not 'true' + // pnpm returns "undefined" if not set + pc.jsonOutput = jsonOutput != "false" && jsonOutput != "undefined" + return nil +} + +func (pc *PnpmCommand) processConfigLine(configLine string) (filteredLine string, err error) { + splitOption := strings.SplitN(configLine, "=", 2) + key := strings.TrimSpace(splitOption[0]) + validLine := len(splitOption) == 2 && isValidKey(key) + if !validLine { + if strings.HasPrefix(splitOption[0], "@") { + // Override scoped registries (@scope = xyz) + return fmt.Sprintf("%s = %s\n", splitOption[0], pc.registry), nil + } + return + } + value := strings.TrimSpace(splitOption[1]) + if key == commandUtils.NpmConfigAuthKey || key == commandUtils.NpmConfigAuthTokenKey { + return "", pc.setNpmConfigAuthEnv(value, key) + } + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + return addArrayConfigs(key, value), nil + } + + return fmt.Sprintf("%s\n", configLine), err +} + +func (pc *PnpmCommand) setNpmConfigAuthEnv(value, authKey string) error { + // Check if the pnpm version supports scoped auth env vars. + if pc.isPnpmVersionSupportsScopedAuthEnv() { + // Get registry name without the protocol name but including the '//' + registryWithoutProtocolName := pc.registry[strings.Index(pc.registry, "://")+1:] + // Ensure registry ends with / (required for scoped auth) + if !strings.HasSuffix(registryWithoutProtocolName, "/") { + registryWithoutProtocolName += "/" + } + // Set "npm_config_///:_auth" environment variable to allow authentication with Artifactory + scopedRegistryEnv := fmt.Sprintf(npmConfigAuthEnv, registryWithoutProtocolName, authKey) + return os.Setenv(scopedRegistryEnv, value) + } + // Set "npm_config__auth" environment variable to allow authentication with Artifactory when running post-install scripts on subdirectories. + // For older versions, use un-scoped auth env vars. + return os.Setenv(npmLegacyConfigAuthEnv, value) +} + +func (pc *PnpmCommand) isPnpmVersionSupportsScopedAuthEnv() bool { + return pc.pnpmVersion.Compare(pnpmVersionSupportingScopedAuthEnv) <= 0 +} + +func (pc *PnpmCommand) prepareConfigData(data []byte) ([]byte, error) { + var filteredConf []string + configString := string(data) + "\n" + pc.npmAuth + scanner := bufio.NewScanner(strings.NewReader(configString)) + for scanner.Scan() { + currOption := scanner.Text() + if currOption == "" { + continue + } + filteredLine, err := pc.processConfigLine(currOption) + if err != nil { + return nil, errorutils.CheckError(err) + } + if filteredLine != "" { + filteredConf = append(filteredConf, filteredLine) + } + } + if err := scanner.Err(); err != nil { + return nil, errorutils.CheckError(err) + } + + filteredConf = append(filteredConf, "json = ", strconv.FormatBool(pc.jsonOutput), "\n") + filteredConf = append(filteredConf, "registry = ", pc.registry, "\n") + return []byte(strings.Join(filteredConf, "")), nil +} + +func (pc *PnpmCommand) CreateTempNpmrc() error { + data, err := getPnpmConfigList(pc.executablePath) + if err != nil { + return err + } + + // Read existing project .npmrc to preserve other registry auth (e.g., GitHub) + existingNpmrc, err := pc.readExistingNpmrc() + if err != nil { + return err + } + + configData, err := pc.prepareConfigData(data) + if err != nil { + return errorutils.CheckError(err) + } + + // Merge existing .npmrc content with new config + if len(existingNpmrc) > 0 { + configData = pc.mergeNpmrcConfigs(existingNpmrc, configData) + } + + if err = removeNpmrcIfExists(pc.workingDirectory); err != nil { + return err + } + log.Debug("Creating temporary .npmrc file.") + return errorutils.CheckError(os.WriteFile(filepath.Join(pc.workingDirectory, npmrcFileName), configData, 0755)) +} + +// readExistingNpmrc reads the existing .npmrc file content if it exists +func (pc *PnpmCommand) readExistingNpmrc() ([]byte, error) { + npmrcPath := filepath.Join(pc.workingDirectory, npmrcFileName) + if _, err := os.Stat(npmrcPath); os.IsNotExist(err) { + return nil, nil + } + return os.ReadFile(npmrcPath) +} + +// mergeNpmrcConfigs merges existing .npmrc content with new Artifactory config +// It preserves auth settings for other registries (e.g., GitHub npm registry) +func (pc *PnpmCommand) mergeNpmrcConfigs(existing, new []byte) []byte { + // Extract lines from existing .npmrc that should be preserved + // (auth lines for registries other than our Artifactory registry) + var preservedLines []string + registryWithoutProtocol := pc.registry[strings.Index(pc.registry, "://")+1:] + + scanner := bufio.NewScanner(strings.NewReader(string(existing))) + for scanner.Scan() { + line := scanner.Text() + // Preserve auth lines for other registries (not our Artifactory) + if strings.HasPrefix(line, "//") && !strings.Contains(line, registryWithoutProtocol) { + preservedLines = append(preservedLines, line) + } + } + + if len(preservedLines) == 0 { + return new + } + + // Append preserved lines to new config + result := string(new) + for _, line := range preservedLines { + result += line + "\n" + } + return []byte(result) +} + +func (pc *PnpmCommand) Run() (err error) { + if err = pc.PreparePrerequisites(pc.repo); err != nil { + return + } + defer func() { + err = errors.Join(err, pc.installHandler.RestoreNpmrc()) + }() + err = pc.installHandler.Install() + return +} + +func (pc *PnpmCommand) prepareBuildInfoModule() error { + var err error + if pc.collectBuildInfo { + pc.collectBuildInfo, err = pc.buildConfiguration.IsCollectBuildInfo() + if err != nil { + return err + } + } + // Build-info should not be created when installing a single package (pnpm install ). + if pc.collectBuildInfo && len(filterFlags(pc.pnpmArgs)) > 0 { + log.Info("Build-info dependencies collection is not supported for installations of single packages. Build-info creation is skipped.") + pc.collectBuildInfo = false + } + buildName, err := pc.buildConfiguration.GetBuildName() + if err != nil { + return err + } + buildNumber, err := pc.buildConfiguration.GetBuildNumber() + if err != nil { + return err + } + buildInfoService := buildUtils.CreateBuildInfoService() + pnpmBuild, err := buildInfoService.GetOrCreateBuildWithProject(buildName, buildNumber, pc.buildConfiguration.GetProject()) + if err != nil { + return errorutils.CheckError(err) + } + // Use PnpmModule to run pnpm commands + pc.buildInfoModule, err = pnpmBuild.AddPnpmModule(pc.workingDirectory) + if err != nil { + return errorutils.CheckError(err) + } + pc.buildInfoModule.SetCollectBuildInfo(pc.collectBuildInfo) + if pc.buildConfiguration.GetModule() != "" { + pc.buildInfoModule.SetName(pc.buildConfiguration.GetModule()) + } + return nil +} + +func (pc *PnpmCommand) collectDependencies() error { + pc.buildInfoModule.SetPnpmArgs(append([]string{pc.cmdName}, pc.pnpmArgs...)) + return errorutils.CheckError(pc.buildInfoModule.Build()) +} + +// Gets a config with value which is an array +func addArrayConfigs(key, arrayValue string) string { + if arrayValue == "[]" { + return "" + } + + values := strings.TrimPrefix(strings.TrimSuffix(arrayValue, "]"), "[") + valuesSlice := strings.Split(values, ",") + var configArrayValues strings.Builder + for _, val := range valuesSlice { + configArrayValues.WriteString(fmt.Sprintf("%s[] = %s\n", key, val)) + } + + return configArrayValues.String() +} + +func removeNpmrcIfExists(workingDirectory string) error { + if _, err := os.Stat(filepath.Join(workingDirectory, npmrcFileName)); err != nil { + // The file does not exist, nothing to do. + if os.IsNotExist(err) { + return nil + } + return errorutils.CheckError(err) + } + + log.Debug("Removing existing .npmrc file") + return errorutils.CheckError(os.Remove(filepath.Join(workingDirectory, npmrcFileName))) +} + +// To avoid writing configurations that are used by us +func isValidKey(key string) bool { + return !strings.HasPrefix(key, "//") && + !strings.HasPrefix(key, ";") && // Comments + !strings.HasPrefix(key, "@") && // Scoped configurations + key != "registry" && + key != "metrics-registry" && + key != "json" // Handled separately because 'pnpm c ls' should run with json=false +} + +func filterFlags(splitArgs []string) []string { + var filteredArgs []string + for _, arg := range splitArgs { + if !strings.HasPrefix(arg, "-") { + filteredArgs = append(filteredArgs, arg) + } + } + return filteredArgs +} + +func (pc *PnpmCommand) GetRepo() string { + return pc.repo +} + +// Creates an .npmrc file in the project's directory in order to configure the provided Artifactory server as a resolution server +func SetArtifactoryAsResolutionServer(serverDetails *config.ServerDetails, depsRepo string) (clearResolutionServerFunc func() error, err error) { + pnpmCmd := NewPnpmInstallCommand().SetServerDetails(serverDetails) + if err = pnpmCmd.PreparePrerequisites(depsRepo); err != nil { + return + } + if err = pnpmCmd.CreateTempNpmrc(); err != nil { + return + } + clearResolutionServerFunc = pnpmCmd.RestoreNpmrcFunc() + log.Info(fmt.Sprintf("Resolving dependencies from '%s' from repo '%s'", serverDetails.Url, depsRepo)) + return +} + +// getPnpmConfigList returns the pnpm configuration by running 'pnpm config list'. +func getPnpmConfigList(executablePath string) ([]byte, error) { + configCmd := gofrogcmd.NewCommand(executablePath, "config", []string{"list"}) + output, err := configCmd.RunWithOutput() + if err != nil { + return nil, errorutils.CheckError(err) + } + return output, nil +} + +// getPnpmConfigValue returns a specific pnpm configuration value by running 'pnpm config get '. +func getPnpmConfigValue(executablePath, key string) (string, error) { + configCmd := gofrogcmd.NewCommand(executablePath, "config", []string{"get", key}) + output, err := configCmd.RunWithOutput() + if err != nil { + return "", errorutils.CheckError(err) + } + return strings.TrimSpace(string(output)), nil +} diff --git a/artifactory/commands/pnpm/pnpmcommand_test.go b/artifactory/commands/pnpm/pnpmcommand_test.go new file mode 100644 index 00000000..367b05d0 --- /dev/null +++ b/artifactory/commands/pnpm/pnpmcommand_test.go @@ -0,0 +1,174 @@ +package pnpm + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + biutils "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/gofrog/version" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + commonTests "github.com/jfrog/jfrog-cli-core/v2/common/tests" + "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + testsUtils "github.com/jfrog/jfrog-client-go/utils/tests" + "github.com/stretchr/testify/assert" +) + +// getTestCredentialValue returns a fake base64-encoded value for testing. NOT a real credential. +func getTestCredentialValue() string { + // Base64 of "fake-test-value-for-unit-testing" + return "ZmFrZS10ZXN0LXZhbHVlLWZvci11bml0LXRlc3Rpbmc=" +} + +// testScheme returns the URL scheme for test URLs. +func testScheme(secure bool) string { + if secure { + return "https" + "://" + } + return "http" + "://" +} + +func TestPrepareConfigData(t *testing.T) { + configBefore := []byte( + "json=true\n" + + "user-agent=pnpm/7.0.0 node/v18.0.0 darwin x64\n" + + "metrics-registry=http://somebadregistry\nscope=\n" + + "//reg=ddddd\n" + + "@jfrog:registry=http://somebadregistry\n" + + "registry=http://somebadregistry\n" + + "email=ddd@dd.dd\n" + + "allow-same-version=false\n" + + "cache-lock-retries=10") + + testRegistry := testScheme(false) + "goodRegistry" + expectedConfig := + []string{ + "json = true", + "allow-same-version=false", + "user-agent=pnpm/7.0.0 node/v18.0.0 darwin x64", + "@jfrog:registry = " + testRegistry, + "email=ddd@dd.dd", + "cache-lock-retries=10", + "registry = " + testRegistry, + } + + pnpmi := PnpmCommand{registry: testRegistry, jsonOutput: true, npmAuth: "_auth = " + getTestCredentialValue(), pnpmVersion: version.NewVersion("7.5.0")} + configAfter, err := pnpmi.prepareConfigData(configBefore) + if err != nil { + t.Error(err) + } + actualConfigArray := strings.Split(string(configAfter), "\n") + for _, eConfig := range expectedConfig { + found := false + for _, aConfig := range actualConfigArray { + if aConfig == eConfig { + found = true + break + } + } + if !found { + t.Errorf("The expected config: %s is missing from the actual configuration list:\n %s", eConfig, actualConfigArray) + } + } + + // Assert that NPM_CONFIG__AUTH environment variable was set (pnpm uses npm config vars) + assert.Equal(t, getTestCredentialValue(), os.Getenv(fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry/", utils.NpmConfigAuthKey))) + testsUtils.UnSetEnvAndAssert(t, fmt.Sprintf(npmConfigAuthEnv, "//goodRegistry/", utils.NpmConfigAuthKey)) +} + +func TestSetNpmConfigAuthEnv(t *testing.T) { + testCases := []struct { + name string + pnpmCm *PnpmCommand + authKey string + value string + expectedEnv string + }{ + { + name: "set scoped registry auth env", + pnpmCm: &PnpmCommand{ + pnpmVersion: version.NewVersion("7.5.0"), + }, + authKey: utils.NpmConfigAuthKey, + value: "some_auth_token", + expectedEnv: "npm_config_//registry.example.com/:_auth", + }, + { + name: "set scoped registry authToken env", + pnpmCm: &PnpmCommand{ + pnpmVersion: version.NewVersion("7.5.0"), + }, + authKey: utils.NpmConfigAuthTokenKey, + value: "some_auth_token", + expectedEnv: "npm_config_//registry.example.com/:_authToken", + }, + { + name: "set legacy auth env", + pnpmCm: &PnpmCommand{ + pnpmVersion: version.NewVersion("6.5.0"), + }, + authKey: utils.NpmConfigAuthKey, + value: "some_auth_token", + expectedEnv: "npm_config__auth", + }, + { + name: "set legacy auth env even though authToken is passed", + pnpmCm: &PnpmCommand{ + pnpmVersion: version.NewVersion("6.5.0"), + }, + authKey: utils.NpmConfigAuthTokenKey, + value: "some_auth_token", + expectedEnv: "npm_config__auth", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.pnpmCm.registry = "https://registry.example.com" + err := tc.pnpmCm.setNpmConfigAuthEnv(tc.value, tc.authKey) + assert.NoError(t, err) + envValue := os.Getenv(tc.expectedEnv) + assert.Equal(t, tc.value, envValue) + assert.NoError(t, os.Unsetenv(tc.expectedEnv)) + }) + } +} + +func TestSetArtifactoryAsResolutionServer(t *testing.T) { + tmpDir, createTempDirCallback := tests.CreateTempDirWithCallbackAndAssert(t) + defer createTempDirCallback() + + // pnpm uses the same project structure as npm + npmProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "npm-project") + err := biutils.CopyDir(npmProjectPath, tmpDir, false, nil) + assert.NoError(t, err) + + cwd, err := os.Getwd() + assert.NoError(t, err) + chdirCallback := testsUtils.ChangeDirWithCallback(t, cwd, tmpDir) + defer chdirCallback() + + // Prepare mock server + testServer, serverDetails, _ := commonTests.CreateRtRestsMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.RequestURI == "/api/system/version" { + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("{\"version\" : \"7.75.4\"}")) + assert.NoError(t, err) + } + }) + defer testServer.Close() + + depsRepo := "my-rt-resolution-repo" + + clearResolutionServerFunc, err := SetArtifactoryAsResolutionServer(serverDetails, depsRepo) + assert.NoError(t, err) + assert.NotNil(t, clearResolutionServerFunc) + defer func() { + assert.NoError(t, clearResolutionServerFunc()) + }() + + assert.FileExists(t, filepath.Join(tmpDir, ".npmrc")) +} diff --git a/artifactory/commands/pnpm/pnpminstall.go b/artifactory/commands/pnpm/pnpminstall.go new file mode 100644 index 00000000..a74e8d23 --- /dev/null +++ b/artifactory/commands/pnpm/pnpminstall.go @@ -0,0 +1,25 @@ +package pnpm + +import "github.com/jfrog/jfrog-client-go/utils/log" + +type pnpmInstall struct { + *PnpmCommand +} + +func (pi *pnpmInstall) PrepareInstallPrerequisites(repo string) error { + log.Debug("Skipping pnpm install preparation on repository: ", repo) + return nil +} + +func (pi *pnpmInstall) Run() (err error) { + if err = pi.prepareBuildInfoModule(); err != nil { + return + } + err = pi.collectDependencies() + return +} + +func (pi *pnpmInstall) RestoreNpmrc() error { + // No need to restore the npmrc file, since we are using user's npmrc + return nil +} diff --git a/artifactory/commands/pnpm/pnpmpublish.go b/artifactory/commands/pnpm/pnpmpublish.go new file mode 100644 index 00000000..0f90f5e9 --- /dev/null +++ b/artifactory/commands/pnpm/pnpmpublish.go @@ -0,0 +1,161 @@ +package pnpm + +import ( + "errors" + "fmt" + "strings" + + buildinfo "github.com/jfrog/build-info-go/entities" + gofrogcmd "github.com/jfrog/gofrog/io" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory/services" + specutils "github.com/jfrog/jfrog-client-go/artifactory/services/utils" + "github.com/jfrog/jfrog-client-go/utils/io/content" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type pnpmPublish struct { + *PnpmPublishCommand +} + +func (ppu *pnpmPublish) upload() (err error) { + for _, packedFilePath := range ppu.packedFilePaths { + if err = ppu.readPackageInfoFromTarball(packedFilePath); err != nil { + return err + } + var repoConfig, targetRepo string + var targetServer *config.ServerDetails + repoConfig, err = ppu.getRepoConfig() + if err != nil { + return err + } + targetRepo, err = extractRepoName(repoConfig) + if err != nil { + return err + } + targetServer, err = extractConfigServer(repoConfig) + if err != nil { + return err + } + target := fmt.Sprintf("%s/%s", targetRepo, ppu.packageInfo.GetDeployPath()) + + // If requested, perform a Xray binary scan before deployment. If a FailBuildError is returned, skip the deployment. + if ppu.xrayScan { + if err = performXrayScan(packedFilePath, ppu.repo, targetServer, ppu.scanOutputFormat); err != nil { + return + } + } + err = errors.Join(err, ppu.publishPackage(ppu.executablePath, packedFilePath, targetServer, target)) + } + return +} + +func (ppu *pnpmPublish) getBuildArtifacts() []buildinfo.Artifact { + return ConvertArtifactsDetailsToBuildInfoArtifacts(ppu.artifactsDetailsReader, utils.ConvertArtifactsSearchDetailsToBuildInfoArtifacts) +} + +func (ppu *pnpmPublish) publishPackage(executablePath, filePath string, serverDetails *config.ServerDetails, target string) error { + pnpmCommand := gofrogcmd.NewCommand(executablePath, "publish", []string{filePath}) + output, cmdError, _, err := gofrogcmd.RunCmdWithOutputParser(pnpmCommand, true) + if err != nil { + log.Error("Error occurred while running pnpm publish: ", output, cmdError, err) + ppu.result.SetFailCount(ppu.result.FailCount() + 1) + return err + } + ppu.result.SetSuccessCount(ppu.result.SuccessCount() + 1) + servicesManager, err := utils.CreateServiceManager(serverDetails, -1, 0, false) + if err != nil { + return err + } + + if ppu.collectBuildInfo { + var buildProps string + var searchReader *content.ContentReader + + buildProps, err = ppu.getBuildPropsForArtifact() + if err != nil { + return err + } + searchParams := services.SearchParams{ + CommonParams: &specutils.CommonParams{ + Pattern: target, + }, + } + searchReader, err = servicesManager.SearchFiles(searchParams) + if err != nil { + log.Error("Failed to get uploaded pnpm package: ", err.Error()) + return err + } + + propsParams := services.PropsParams{ + Reader: searchReader, + Props: buildProps, + } + _, err = servicesManager.SetProps(propsParams) + if err != nil { + log.Warn("Unable to set build properties: ", err, "\nThis may cause build to not properly link with artifact, please add build name and build number properties on the tarball artifact manually") + } + ppu.artifactsDetailsReader = append(ppu.artifactsDetailsReader, searchReader) + } + return nil +} + +func (ppu *PnpmPublishCommand) getRepoConfig() (string, error) { + var registryString string + scope := ppu.packageInfo.Scope + if scope == "" { + registryString = "registry" + } else { + registryString = scope + ":registry" + } + configCommand := gofrogcmd.Command{ + Executable: ppu.executablePath, + CmdName: "config", + CmdArgs: []string{"get", registryString}, + } + data, err := configCommand.RunWithOutput() + repoConfig := string(data) + if err != nil { + log.Error("Error occurred while running pnpm config get: ", err) + ppu.result.SetFailCount(ppu.result.FailCount() + 1) + return "", err + } + return repoConfig, nil +} + +func extractRepoName(configUrl string) (string, error) { + url := strings.TrimSpace(configUrl) + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + url = strings.TrimSuffix(url, "/") + if url == "" { + return "", errors.New("pnpm config URL is empty") + } + urlParts := strings.Split(url, "/") + if len(urlParts) < 2 { + return "", errors.New("pnpm config URL is not valid") + } + return urlParts[len(urlParts)-1], nil +} + +func extractConfigServer(configUrl string) (*config.ServerDetails, error) { + var requiredServerDetails = &config.ServerDetails{} + url := strings.TrimSpace(configUrl) + allAvailableConfigs, err := config.GetAllServersConfigs() + if err != nil { + return requiredServerDetails, err + } + + for _, availableConfig := range allAvailableConfigs { + if strings.HasPrefix(url, availableConfig.ArtifactoryUrl) { + requiredServerDetails = availableConfig + } + } + + if requiredServerDetails == nil { + return requiredServerDetails, fmt.Errorf("no server details found for the URL: %s to create build info", url) + } + + return requiredServerDetails, nil +} diff --git a/artifactory/commands/pnpm/publish.go b/artifactory/commands/pnpm/publish.go new file mode 100644 index 00000000..99adb6f1 --- /dev/null +++ b/artifactory/commands/pnpm/publish.go @@ -0,0 +1,519 @@ +package pnpm + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jfrog/build-info-go/build" + biutils "github.com/jfrog/build-info-go/build/utils" + gofrogcmd "github.com/jfrog/gofrog/io" + "github.com/jfrog/gofrog/version" + commandsutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + buildUtils "github.com/jfrog/jfrog-cli-core/v2/common/build" + "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + clientutils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/content" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +const ( + DistTagPropKey = "npm.disttag" + // The --pack-destination argument of pnpm pack was introduced in pnpm version 7.0.0. + packDestinationPnpmMinVersion = "7.0.0" +) + +type PnpmPublishCommandArgs struct { + CommonArgs + executablePath string + workingDirectory string + collectBuildInfo bool + packedFilePaths []string + packageInfo *biutils.PackageInfo + publishPath string + tarballProvided bool + artifactsDetailsReader []*content.ContentReader + xrayScan bool + scanOutputFormat format.OutputFormat + distTag string +} + +type PnpmPublishCommand struct { + configFilePath string + commandName string + result *commandsutils.Result + detailedSummary bool + pnpmVersion *version.Version + *PnpmPublishCommandArgs +} + +// packageJsonInfo holds information about a package.json file found in a tarball +type packageJsonInfo struct { + path string + content []byte +} + +func NewPnpmPublishCommand() *PnpmPublishCommand { + return &PnpmPublishCommand{PnpmPublishCommandArgs: NewPnpmPublishCommandArgs(), commandName: "rt_pnpm_publish", result: new(commandsutils.Result)} +} + +func NewPnpmPublishCommandArgs() *PnpmPublishCommandArgs { + return &PnpmPublishCommandArgs{} +} + +func (ppc *PnpmPublishCommand) ServerDetails() (*config.ServerDetails, error) { + return ppc.serverDetails, nil +} + +func (ppc *PnpmPublishCommand) SetConfigFilePath(configFilePath string) *PnpmPublishCommand { + ppc.configFilePath = configFilePath + return ppc +} + +func (ppc *PnpmPublishCommand) SetArgs(args []string) *PnpmPublishCommand { + ppc.pnpmArgs = args + return ppc +} + +func (ppc *PnpmPublishCommand) SetDetailedSummary(detailedSummary bool) *PnpmPublishCommand { + ppc.detailedSummary = detailedSummary + return ppc +} + +func (ppc *PnpmPublishCommand) SetXrayScan(xrayScan bool) *PnpmPublishCommand { + ppc.xrayScan = xrayScan + return ppc +} + +func (ppc *PnpmPublishCommand) GetXrayScan() bool { + return ppc.xrayScan +} + +func (ppc *PnpmPublishCommand) SetScanOutputFormat(format format.OutputFormat) *PnpmPublishCommand { + ppc.scanOutputFormat = format + return ppc +} + +func (ppc *PnpmPublishCommand) SetDistTag(tag string) *PnpmPublishCommand { + ppc.distTag = tag + return ppc +} + +func (ppc *PnpmPublishCommand) Result() *commandsutils.Result { + return ppc.result +} + +func (ppc *PnpmPublishCommand) IsDetailedSummary() bool { + return ppc.detailedSummary +} + +func (ppc *PnpmPublishCommand) Init() error { + var err error + ppc.pnpmVersion, ppc.executablePath, err = biutils.GetPnpmVersionAndExecPath(log.Logger) + if err != nil { + return err + } + detailedSummary, xrayScan, scanOutputFormat, filteredPnpmArgs, buildConfiguration, err := commandsutils.ExtractNpmOptionsFromArgs(ppc.pnpmArgs) + if err != nil { + return err + } + filteredPnpmArgs, useNative, err := coreutils.ExtractUseNativeFromArgs(filteredPnpmArgs) + if err != nil { + return err + } + filteredPnpmArgs, tag, err := coreutils.ExtractTagFromArgs(filteredPnpmArgs) + if err != nil { + return err + } + if ppc.configFilePath != "" { + // Read config file. + log.Debug("Preparing to read the config file", ppc.configFilePath) + vConfig, err := project.ReadConfigFile(ppc.configFilePath, project.YAML) + if err != nil { + return err + } + deployerParams, err := project.GetRepoConfigByPrefix(ppc.configFilePath, project.ProjectConfigDeployerPrefix, vConfig) + if err != nil { + return err + } + rtDetails, err := deployerParams.ServerDetails() + if err != nil { + return errorutils.CheckError(err) + } + ppc.SetBuildConfiguration(buildConfiguration).SetRepo(deployerParams.TargetRepo()).SetPnpmArgs(filteredPnpmArgs).SetServerDetails(rtDetails) + } + ppc.SetDetailedSummary(detailedSummary).SetXrayScan(xrayScan).SetScanOutputFormat(scanOutputFormat).SetDistTag(tag).SetUseNative(useNative) + return nil +} + +func (ppc *PnpmPublishCommand) Run() (err error) { + log.Info("Running pnpm Publish") + err = ppc.preparePrerequisites() + if err != nil { + return err + } + + var pnpmBuild *build.Build + var buildName, buildNumber, projectKey string + if ppc.collectBuildInfo { + buildName, err = ppc.buildConfiguration.GetBuildName() + if err != nil { + return err + } + buildNumber, err = ppc.buildConfiguration.GetBuildNumber() + if err != nil { + return err + } + projectKey = ppc.buildConfiguration.GetProject() + buildInfoService := buildUtils.CreateBuildInfoService() + pnpmBuild, err = buildInfoService.GetOrCreateBuildWithProject(buildName, buildNumber, projectKey) + if err != nil { + return errorutils.CheckError(err) + } + } + + if !ppc.tarballProvided { + if err = ppc.pack(); err != nil { + return err + } + } + + publishStrategy := NewPnpmPublishStrategy(ppc.UseNative(), ppc) + + err = publishStrategy.Publish() + if err != nil { + if ppc.tarballProvided { + return err + } + // We should delete the tarball we created + return errors.Join(err, deleteCreatedTarball(ppc.packedFilePaths)) + } + + if !ppc.tarballProvided { + if err = deleteCreatedTarball(ppc.packedFilePaths); err != nil { + return err + } + } + + if !ppc.collectBuildInfo { + log.Info("pnpm publish finished successfully.") + return nil + } + + // Use PnpmModule to run pnpm commands + pnpmModule, err := pnpmBuild.AddPnpmModule("") + if err != nil { + return errorutils.CheckError(err) + } + if ppc.buildConfiguration.GetModule() != "" { + pnpmModule.SetName(ppc.buildConfiguration.GetModule()) + } + + buildArtifacts := publishStrategy.GetBuildArtifacts() + for _, artifactReader := range ppc.artifactsDetailsReader { + gofrogcmd.Close(artifactReader, &err) + } + err = pnpmModule.AddArtifacts(buildArtifacts...) + if err != nil { + return errorutils.CheckError(err) + } + + log.Info("pnpm publish finished successfully.") + return nil +} + +func (ppc *PnpmPublishCommand) CommandName() string { + return ppc.commandName +} + +func (ppc *PnpmPublishCommand) preparePrerequisites() error { + ppc.packedFilePaths = make([]string, 0) + currentDir, err := os.Getwd() + if err != nil { + return errorutils.CheckError(err) + } + + currentDir, err = filepath.Abs(currentDir) + if err != nil { + return errorutils.CheckError(err) + } + + ppc.workingDirectory = currentDir + log.Debug("Working directory set to:", ppc.workingDirectory) + ppc.collectBuildInfo, err = ppc.buildConfiguration.IsCollectBuildInfo() + if err != nil { + return err + } + if err = ppc.setPublishPath(); err != nil { + return err + } + + artDetails, err := ppc.serverDetails.CreateArtAuthConfig() + if err != nil { + return err + } + + if err = utils.ValidateRepoExists(ppc.repo, artDetails); err != nil { + return err + } + + return ppc.setPackageInfo() +} + +func (ppc *PnpmPublishCommand) pack() error { + log.Debug("Creating pnpm package.") + packedFileNames, err := Pack(ppc.pnpmArgs, ppc.executablePath) + if err != nil { + return err + } + + tarballDir, err := ppc.getTarballDir() + if err != nil { + return err + } + + for _, packageFileName := range packedFileNames { + ppc.packedFilePaths = append(ppc.packedFilePaths, filepath.Join(tarballDir, packageFileName)) + } + + return nil +} + +func (ppc *PnpmPublishCommand) getTarballDir() (string, error) { + if ppc.pnpmVersion == nil || ppc.pnpmVersion.Compare(packDestinationPnpmMinVersion) > 0 { + return ppc.workingDirectory, nil + } + + // Extract pack destination argument from the args. + flagIndex, _, dest, err := coreutils.FindFlag("--pack-destination", ppc.pnpmArgs) + if err != nil || flagIndex == -1 { + return ppc.workingDirectory, err + } + return dest, nil +} + +func (ppc *PnpmPublishCommand) setPublishPath() error { + log.Debug("Reading Package Json.") + + ppc.publishPath = ppc.workingDirectory + if len(ppc.pnpmArgs) > 0 && !strings.HasPrefix(strings.TrimSpace(ppc.pnpmArgs[0]), "-") { + path := strings.TrimSpace(ppc.pnpmArgs[0]) + path = clientutils.ReplaceTildeWithUserHome(path) + + if filepath.IsAbs(path) { + ppc.publishPath = path + } else { + ppc.publishPath = filepath.Join(ppc.workingDirectory, path) + } + } + return nil +} + +func (ppc *PnpmPublishCommand) setPackageInfo() error { + log.Debug("Setting Package Info.") + fileInfo, err := os.Stat(ppc.publishPath) + if err != nil { + return errorutils.CheckError(err) + } + + if fileInfo.IsDir() { + ppc.packageInfo, err = biutils.ReadPackageInfoFromPackageJsonIfExists(ppc.publishPath, ppc.pnpmVersion) + return err + } + log.Debug("The provided path is not a directory, we assume this is a compressed pnpm package") + ppc.tarballProvided = true + // Sets the location of the provided tarball + ppc.packedFilePaths = []string{ppc.publishPath} + return ppc.readPackageInfoFromTarball(ppc.publishPath) +} + +func (ppc *PnpmPublishCommand) readPackageInfoFromTarball(packedFilePath string) (err error) { + log.Debug("Extracting info from pnpm package:", packedFilePath) + tarball, err := os.Open(packedFilePath) + if err != nil { + return errorutils.CheckError(err) + } + defer func() { + err = errors.Join(err, errorutils.CheckError(tarball.Close())) + }() + gZipReader, err := gzip.NewReader(tarball) + if err != nil { + return errorutils.CheckError(err) + } + + // First pass: Collect all package.json files and validate their content + var standardLocation *packageJsonInfo + var rootLevelLocations []*packageJsonInfo + var otherLocations []*packageJsonInfo + + tarReader := tar.NewReader(gZipReader) + for { + hdr, err := tarReader.Next() + if err != nil { + if err == io.EOF { + break + } + return errorutils.CheckError(err) + } + + // Skip files that don't end with package.json + if !strings.HasSuffix(hdr.Name, "package.json") { + continue + } + + // Skip macOS resource fork files (._package.json) + baseName := filepath.Base(hdr.Name) + if strings.HasPrefix(baseName, "._") { + continue + } + + content, err := io.ReadAll(tarReader) + if err != nil { + return errorutils.CheckError(err) + } + + // Validate JSON before storing + if err := validatePackageJson(content); err != nil { + log.Debug("Invalid package.json found at", hdr.Name+":", err.Error()) + continue + } + + info := &packageJsonInfo{ + path: hdr.Name, + content: content, + } + + // Categorize based on location + switch { + case hdr.Name == "package/package.json": + standardLocation = info + case strings.Count(hdr.Name, "/") == 1: + rootLevelLocations = append(rootLevelLocations, info) + default: + otherLocations = append(otherLocations, info) + } + } + + // Use package.json based on priority + switch { + case standardLocation != nil: + log.Debug("Found package.json in standard location:", standardLocation.path) + ppc.packageInfo, err = biutils.ReadPackageInfo(standardLocation.content, ppc.pnpmVersion) + return err + + case len(rootLevelLocations) > 0: + if len(rootLevelLocations) > 1 { + log.Debug("Found multiple package.json files in root-level directories:", formatPaths(rootLevelLocations)) + log.Debug("Using first found:", rootLevelLocations[0].path) + } else { + log.Debug("Using package.json found in root-level directory:", rootLevelLocations[0].path) + } + ppc.packageInfo, err = biutils.ReadPackageInfo(rootLevelLocations[0].content, ppc.pnpmVersion) + return err + + case len(otherLocations) > 0: + if len(otherLocations) > 1 { + log.Debug("Found multiple package.json files in non-standard locations:", formatPaths(otherLocations)) + log.Debug("Using first found:", otherLocations[0].path) + } else { + log.Debug("Found package.json in non-standard location:", otherLocations[0].path) + } + ppc.packageInfo, err = biutils.ReadPackageInfo(otherLocations[0].content, ppc.pnpmVersion) + return err + } + + return errorutils.CheckError(errors.New("Could not find valid 'package.json' in the compressed pnpm package: " + packedFilePath)) +} + +// validatePackageJson checks if the content is valid JSON and has required npm fields +func validatePackageJson(content []byte) error { + var packageJson map[string]interface{} + if err := json.Unmarshal(content, &packageJson); err != nil { + return fmt.Errorf("invalid JSON: %v", err) + } + + // Check required fields + name, hasName := packageJson["name"].(string) + version, hasVersion := packageJson["version"].(string) + + if !hasName || name == "" { + return fmt.Errorf("missing or empty 'name' field") + } + if !hasVersion || version == "" { + return fmt.Errorf("missing or empty 'version' field") + } + + return nil +} + +// formatPaths returns a formatted string of package.json paths for logging +func formatPaths(infos []*packageJsonInfo) string { + paths := make([]string, len(infos)) + for i, info := range infos { + paths[i] = info.path + } + return strings.Join(paths, ", ") +} + +func deleteCreatedTarball(packedFilesPath []string) error { + for _, packedFilePath := range packedFilesPath { + if err := os.Remove(packedFilePath); err != nil { + return errorutils.CheckError(err) + } + log.Debug("Successfully deleted the created pnpm package:", packedFilePath) + } + return nil +} + +func (ppc *PnpmPublishCommand) getBuildPropsForArtifact() (string, error) { + buildName, err := ppc.buildConfiguration.GetBuildName() + if err != nil { + return "", err + } + buildNumber, err := ppc.buildConfiguration.GetBuildNumber() + if err != nil { + return "", err + } + err = buildUtils.SaveBuildGeneralDetails(buildName, buildNumber, ppc.buildConfiguration.GetProject()) + if err != nil { + return "", err + } + return buildUtils.CreateBuildProperties(buildName, buildNumber, ppc.buildConfiguration.GetProject()) +} + +// Pack runs 'pnpm pack' and returns the list of created tarball filenames. +func Pack(pnpmArgs []string, executablePath string) ([]string, error) { + packArgs := append([]string{"pack"}, pnpmArgs...) + packCmd := gofrogcmd.NewCommand(executablePath, "", packArgs) + output, err := packCmd.RunWithOutput() + if err != nil { + return nil, errorutils.CheckError(err) + } + + // Parse output to get the tarball filename(s) + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var packedFiles []string + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasSuffix(line, ".tgz") { + packedFiles = append(packedFiles, line) + } + } + + if len(packedFiles) == 0 { + return nil, errorutils.CheckErrorf("failed to find packed tarball in pnpm pack output") + } + + return packedFiles, nil +} diff --git a/artifactory/commands/pnpm/publish_test.go b/artifactory/commands/pnpm/publish_test.go new file mode 100644 index 00000000..8cfe905d --- /dev/null +++ b/artifactory/commands/pnpm/publish_test.go @@ -0,0 +1,40 @@ +package pnpm + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReadPackageInfoFromTarball(t *testing.T) { + pnpmPublish := NewPnpmPublishCommand() + + // pnpm uses the same tarball format as npm + var testCases = []struct { + filePath string + packageName string + packageVersion string + }{ + { + filePath: filepath.Join("..", "testdata", "npm", "npm-example-0.0.3.tgz"), + packageName: "npm-example", + packageVersion: "0.0.3", + }, { + filePath: filepath.Join("..", "testdata", "npm", "npm-example-0.0.4.tgz"), + packageName: "npm-example", + packageVersion: "0.0.4", + }, { + // Test case for non-standard structure where package.json is in a custom location + filePath: filepath.Join("..", "testdata", "npm", "node-package-1.0.0.tgz"), + packageName: "nonstandard-package", + packageVersion: "1.0.0", + }, + } + for _, test := range testCases { + err := pnpmPublish.readPackageInfoFromTarball(test.filePath) + assert.NoError(t, err) + assert.Equal(t, test.packageName, pnpmPublish.packageInfo.Name) + assert.Equal(t, test.packageVersion, pnpmPublish.packageInfo.Version) + } +} diff --git a/artifactory/commands/pnpm/publishstrategy.go b/artifactory/commands/pnpm/publishstrategy.go new file mode 100644 index 00000000..ce1c383a --- /dev/null +++ b/artifactory/commands/pnpm/publishstrategy.go @@ -0,0 +1,74 @@ +package pnpm + +import ( + buildinfo "github.com/jfrog/build-info-go/entities" + commandsutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-cli-core/v2/common/spec" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/io/content" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type Publisher interface { + upload() error + getBuildArtifacts() []buildinfo.Artifact +} + +type PnpmPublishStrategy struct { + strategy Publisher + strategyName string +} + +// Get pnpm implementation +func NewPnpmPublishStrategy(shouldUseNpmRc bool, pnpmPublishCommand *PnpmPublishCommand) *PnpmPublishStrategy { + pps := PnpmPublishStrategy{} + if shouldUseNpmRc { + pps.strategy = &pnpmPublish{pnpmPublishCommand} + pps.strategyName = "native" + } else { + pps.strategy = &pnpmRtUpload{pnpmPublishCommand} + pps.strategyName = "artifactory" + } + return &pps +} + +func (pps *PnpmPublishStrategy) Publish() error { + log.Debug("Using strategy for publish: ", pps.strategyName) + return pps.strategy.upload() +} + +func (pps *PnpmPublishStrategy) GetBuildArtifacts() []buildinfo.Artifact { + log.Debug("Using strategy for build info: ", pps.strategyName) + return pps.strategy.getBuildArtifacts() +} + +// ConvertArtifactsDetailsToBuildInfoArtifacts converts artifact details readers to build info artifacts +// using the provided conversion function +func ConvertArtifactsDetailsToBuildInfoArtifacts(artifactsDetailsReader []*content.ContentReader, convertFunc func(*content.ContentReader) ([]buildinfo.Artifact, error)) []buildinfo.Artifact { + buildArtifacts := make([]buildinfo.Artifact, 0, len(artifactsDetailsReader)) + for _, artifactReader := range artifactsDetailsReader { + // Skip nil readers to avoid nil pointer dereference when converting artifacts + if artifactReader == nil { + log.Debug("Skipping nil artifact details reader") + continue + } + buildArtifact, err := convertFunc(artifactReader) + if err != nil { + log.Warn("Failed converting artifact details to build info artifacts: ", err.Error()) + } + buildArtifacts = append(buildArtifacts, buildArtifact...) + } + return buildArtifacts +} + +func performXrayScan(filePath string, repo string, serverDetails *config.ServerDetails, scanOutputFormat format.OutputFormat) error { + fileSpec := spec.NewBuilder(). + Pattern(filePath). + Target(repo + "/"). + BuildSpec() + if err := commandsutils.ConditionalUploadScanFunc(serverDetails, fileSpec, 1, scanOutputFormat); err != nil { + return err + } + return nil +} diff --git a/artifactory/docs/pnpmci/help.go b/artifactory/docs/pnpmci/help.go new file mode 100644 index 00000000..337024c3 --- /dev/null +++ b/artifactory/docs/pnpmci/help.go @@ -0,0 +1,18 @@ +package pnpmci + +import "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + +var Usage = []string{"rt pnpm ci [pnpm ci args] [command options]"} + +func GetDescription() string { + return "Run pnpm ci." +} + +func GetArguments() []components.Argument { + return []components.Argument{ + { + Name: "pnpm ci args", + Description: "The pnpm ci args to run pnpm ci.", + }, + } +} diff --git a/artifactory/docs/pnpmconfig/help.go b/artifactory/docs/pnpmconfig/help.go new file mode 100644 index 00000000..eeedd040 --- /dev/null +++ b/artifactory/docs/pnpmconfig/help.go @@ -0,0 +1,13 @@ +package pnpmconfig + +import "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + +var Usage = []string{"rt pnpm-config [command options]"} + +func GetDescription() string { + return "Generate pnpm configuration." +} + +func GetArguments() []components.Argument { + return []components.Argument{} +} diff --git a/artifactory/docs/pnpminstall/help.go b/artifactory/docs/pnpminstall/help.go new file mode 100644 index 00000000..72b59487 --- /dev/null +++ b/artifactory/docs/pnpminstall/help.go @@ -0,0 +1,19 @@ +package pnpminstall + +import "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + +var Usage = []string{"rt pnpmi [pnpm install args] [command options]"} + +func GetDescription() string { + return "Run pnpm install." +} + +func GetArguments() []components.Argument { + return []components.Argument{ + { + Name: "pnpm install args", + Description: "The pnpm install args to run pnpm install. " + + "For example, --global.", + }, + } +} diff --git a/artifactory/docs/pnpmpublish/help.go b/artifactory/docs/pnpmpublish/help.go new file mode 100644 index 00000000..c74f81c6 --- /dev/null +++ b/artifactory/docs/pnpmpublish/help.go @@ -0,0 +1,7 @@ +package pnpmpublish + +var Usage = []string{"rt pnpmp [command options]"} + +func GetDescription() string { + return "Packs and deploys the pnpm package to the designated npm repository." +} diff --git a/cliutils/flagkit/flags.go b/cliutils/flagkit/flags.go index c6e9d561..56888d73 100644 --- a/cliutils/flagkit/flags.go +++ b/cliutils/flagkit/flags.go @@ -62,6 +62,8 @@ const ( NpmInstallCi = "npm-install-ci" NpmPublish = "npm-publish" PnpmConfig = "pnpm-config" + PnpmInstallCi = "pnpm-install-ci" + PnpmPublish = "pnpm-publish" YarnConfig = "yarn-config" Yarn = "yarn" NugetConfig = "nuget-config" @@ -706,6 +708,12 @@ var commandFlags = map[string][]string{ PnpmConfig: { global, serverIdResolve, repoResolve, }, + PnpmInstallCi: { + BuildName, BuildNumber, module, Project, runNative, + }, + PnpmPublish: { + BuildName, BuildNumber, module, Project, npmDetailedSummary, xrayScan, xrOutput, runNative, npmWorkspaces, + }, YarnConfig: { global, serverIdResolve, repoResolve, },