From b6efb6519bfe63f176000b29bccf74dd8296f676 Mon Sep 17 00:00:00 2001 From: Michael Sverdlov Date: Mon, 18 Nov 2024 16:15:20 +0200 Subject: [PATCH] Package manager login command - `Npm`, `Yarn`, `Pip`, `Pipenv`, `Poetry`,`Go`, `Nuget`, `Dotnet` (#1285) --- artifactory/commands/dotnet/dotnetcommand.go | 43 +- .../commands/dotnet/dotnetcommand_test.go | 4 +- artifactory/commands/golang/go.go | 6 +- artifactory/commands/golang/go_test.go | 2 +- .../packagemanagerlogin.go | 241 +++++++++++ .../packagemanagerlogin_test.go | 383 ++++++++++++++++++ artifactory/commands/python/poetry.go | 65 +++ artifactory/commands/python/poetry_test.go | 32 ++ artifactory/commands/python/python.go | 124 ++---- artifactory/commands/python/python_test.go | 34 ++ artifactory/commands/utils/npmcmdutils.go | 50 ++- .../commands/utils/npmcmdutils_test.go | 4 +- .../utils/npm/{config-get.go => configget.go} | 1 + .../npm/{config-list.go => configlist.go} | 0 artifactory/utils/npm/configset.go | 25 ++ artifactory/utils/repositoryutils.go | 27 ++ artifactory/utils/yarn/configget.go | 1 + artifactory/utils/yarn/configset.go | 1 + common/cliutils/utils.go | 1 - 19 files changed, 918 insertions(+), 126 deletions(-) create mode 100644 artifactory/commands/packagemanagerlogin/packagemanagerlogin.go create mode 100644 artifactory/commands/packagemanagerlogin/packagemanagerlogin_test.go create mode 100644 artifactory/commands/python/poetry_test.go create mode 100644 artifactory/commands/python/python_test.go rename artifactory/utils/npm/{config-get.go => configget.go} (88%) rename artifactory/utils/npm/{config-list.go => configlist.go} (100%) create mode 100644 artifactory/utils/npm/configset.go diff --git a/artifactory/commands/dotnet/dotnetcommand.go b/artifactory/commands/dotnet/dotnetcommand.go index a51d62c2e..3f83a14d4 100644 --- a/artifactory/commands/dotnet/dotnetcommand.go +++ b/artifactory/commands/dotnet/dotnetcommand.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/jfrog/build-info-go/build" "github.com/jfrog/build-info-go/build/utils/dotnet" - "github.com/jfrog/gofrog/io" + frogio "github.com/jfrog/gofrog/io" commonBuild "github.com/jfrog/jfrog-cli-core/v2/common/build" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-client-go/auth" @@ -19,7 +19,7 @@ import ( ) const ( - SourceName = "JFrogCli" + SourceName = "JFrogArtifactory" configFilePattern = "jfrog.cli.nuget." dotnetTestError = `the command failed with an error. @@ -159,21 +159,44 @@ func changeWorkingDir(newWorkingDir string) (string, error) { return newWorkingDir, errorutils.CheckError(err) } -// Runs nuget sources add command -func AddSourceToNugetConfig(cmdType dotnet.ToolchainType, configFileName, sourceUrl, user, password string) error { +// Runs nuget/dotnet source add command +func AddSourceToNugetConfig(cmdType dotnet.ToolchainType, sourceUrl, user, password string) error { cmd, err := dotnet.CreateDotnetAddSourceCmd(cmdType, sourceUrl) if err != nil { return err } flagPrefix := cmdType.GetTypeFlagPrefix() - cmd.CommandFlags = append(cmd.CommandFlags, flagPrefix+"configfile", configFileName) cmd.CommandFlags = append(cmd.CommandFlags, flagPrefix+"name", SourceName) cmd.CommandFlags = append(cmd.CommandFlags, flagPrefix+"username", user) cmd.CommandFlags = append(cmd.CommandFlags, flagPrefix+"password", password) - output, err := io.RunCmdOutput(cmd) - log.Debug("'Add sources' command executed. Output:", output) - return err + stdOut, errorOut, _, err := frogio.RunCmdWithOutputParser(cmd, false) + if err != nil { + return fmt.Errorf("failed to add source: %w\n%s", err, strings.TrimSpace(stdOut+errorOut)) + } + return nil +} + +// Runs nuget/dotnet source remove command +func RemoveSourceFromNugetConfigIfExists(cmdType dotnet.ToolchainType) error { + cmd, err := dotnet.NewToolchainCmd(cmdType) + if err != nil { + return err + } + if cmdType == dotnet.DotnetCore { + cmd.Command = append(cmd.Command, "nuget", "remove", "source", SourceName) + } else { + cmd.Command = append(cmd.Command, "sources", "remove") + cmd.CommandFlags = append(cmd.CommandFlags, "-name", SourceName) + } + stdOut, stdErr, _, err := frogio.RunCmdWithOutputParser(cmd, false) + if err != nil { + if strings.Contains(stdOut+stdErr, "Unable to find") { + return nil + } + return fmt.Errorf("failed to remove source: %w\n%s", err, strings.TrimSpace(stdOut+stdErr)) + } + return nil } // Checks if the user provided input such as -configfile flag or -Source flag. @@ -266,7 +289,7 @@ func InitNewConfig(configDirPath, repoName string, server *config.ServerDetails, // Adds a source to the nuget config template func addSourceToNugetTemplate(configFile *os.File, server *config.ServerDetails, useNugetV2 bool, repoName string) error { - sourceUrl, user, password, err := getSourceDetails(server, repoName, useNugetV2) + sourceUrl, user, password, err := GetSourceDetails(server, repoName, useNugetV2) if err != nil { return err } @@ -282,7 +305,7 @@ func addSourceToNugetTemplate(configFile *os.File, server *config.ServerDetails, return err } -func getSourceDetails(details *config.ServerDetails, repoName string, useNugetV2 bool) (sourceURL, user, password string, err error) { +func GetSourceDetails(details *config.ServerDetails, repoName string, useNugetV2 bool) (sourceURL, user, password string, err error) { var u *url.URL u, err = url.Parse(details.ArtifactoryUrl) if errorutils.CheckError(err) != nil { diff --git a/artifactory/commands/dotnet/dotnetcommand_test.go b/artifactory/commands/dotnet/dotnetcommand_test.go index a89fade31..e2aa89f99 100644 --- a/artifactory/commands/dotnet/dotnetcommand_test.go +++ b/artifactory/commands/dotnet/dotnetcommand_test.go @@ -129,14 +129,14 @@ func TestGetSourceDetails(t *testing.T) { Password: "pass", } repoName := "repo-name" - url, user, pass, err := getSourceDetails(server, repoName, false) + url, user, pass, err := GetSourceDetails(server, repoName, false) assert.NoError(t, err) assert.Equal(t, "user", user) assert.Equal(t, "pass", pass) assert.Equal(t, "https://server.com/artifactory/api/nuget/v3/repo-name", url) server.Password = "" server.AccessToken = "abc123" - url, user, pass, err = getSourceDetails(server, repoName, true) + url, user, pass, err = GetSourceDetails(server, repoName, true) assert.Equal(t, "user", user) assert.Equal(t, "abc123", pass) assert.NoError(t, err) diff --git a/artifactory/commands/golang/go.go b/artifactory/commands/golang/go.go index 6671589c7..39ac40ae1 100644 --- a/artifactory/commands/golang/go.go +++ b/artifactory/commands/golang/go.go @@ -154,7 +154,7 @@ func (gc *GoCommand) run() (err error) { return } // If noFallback=false, missing packages will be fetched directly from VCS - repoUrl, err := getArtifactoryRemoteRepoUrl(resolverDetails, gc.resolverParams.TargetRepo(), GoProxyUrlParams{Direct: !gc.noFallback}) + repoUrl, err := GetArtifactoryRemoteRepoUrl(resolverDetails, gc.resolverParams.TargetRepo(), GoProxyUrlParams{Direct: !gc.noFallback}) if err != nil { return } @@ -336,7 +336,7 @@ func SetArtifactoryAsResolutionServer(serverDetails *config.ServerDetails, depsR } func setGoProxy(server *config.ServerDetails, remoteGoRepo string, goProxyParams GoProxyUrlParams) error { - repoUrl, err := getArtifactoryRemoteRepoUrl(server, remoteGoRepo, goProxyParams) + repoUrl, err := GetArtifactoryRemoteRepoUrl(server, remoteGoRepo, goProxyParams) if err != nil { return err } @@ -380,7 +380,7 @@ func (gdu *GoProxyUrlParams) addDirect(url string) string { return url } -func getArtifactoryRemoteRepoUrl(serverDetails *config.ServerDetails, repo string, goProxyParams GoProxyUrlParams) (string, error) { +func GetArtifactoryRemoteRepoUrl(serverDetails *config.ServerDetails, repo string, goProxyParams GoProxyUrlParams) (string, error) { authServerDetails, err := serverDetails.CreateArtAuthConfig() if err != nil { return "", err diff --git a/artifactory/commands/golang/go_test.go b/artifactory/commands/golang/go_test.go index 7949b3062..7b334a3fe 100644 --- a/artifactory/commands/golang/go_test.go +++ b/artifactory/commands/golang/go_test.go @@ -86,7 +86,7 @@ func TestGetArtifactoryRemoteRepoUrl(t *testing.T) { AccessToken: "eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiJmYWtlXC91c2Vyc1wvdGVzdCJ9.MTIzNDU2Nzg5MA", } repoName := "test-repo" - repoUrl, err := getArtifactoryRemoteRepoUrl(server, repoName, GoProxyUrlParams{}) + repoUrl, err := GetArtifactoryRemoteRepoUrl(server, repoName, GoProxyUrlParams{}) assert.NoError(t, err) assert.Equal(t, "https://test:eyJ0eXAiOiJKV1QifQ.eyJzdWIiOiJmYWtlXC91c2Vyc1wvdGVzdCJ9.MTIzNDU2Nzg5MA@server.com/artifactory/api/go/test-repo", repoUrl) } diff --git a/artifactory/commands/packagemanagerlogin/packagemanagerlogin.go b/artifactory/commands/packagemanagerlogin/packagemanagerlogin.go new file mode 100644 index 000000000..2a3958669 --- /dev/null +++ b/artifactory/commands/packagemanagerlogin/packagemanagerlogin.go @@ -0,0 +1,241 @@ +package packagemanagerlogin + +import ( + "fmt" + bidotnet "github.com/jfrog/build-info-go/build/utils/dotnet" + biutils "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/dotnet" + gocommands "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/golang" + pythoncommands "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/python" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/repository" + commandsutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/npm" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/yarn" + "github.com/jfrog/jfrog-cli-core/v2/common/project" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/artifactory/services" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// PackageManagerLoginCommand configures registries and authentication for various package manager (npm, Yarn, Pip, Pipenv, Poetry, Go) +type PackageManagerLoginCommand struct { + // packageManager represents the type of package manager (e.g., NPM, Yarn). + packageManager project.ProjectType + // repoName is the name of the repository used for configuration. + repoName string + // serverDetails contains Artifactory server configuration. + serverDetails *config.ServerDetails + // commandName specifies the command for this instance. + commandName string +} + +// NewPackageManagerLoginCommand initializes a new PackageManagerLoginCommand for the specified package manager +// and automatically sets a command name for the login operation. +func NewPackageManagerLoginCommand(packageManager project.ProjectType) *PackageManagerLoginCommand { + return &PackageManagerLoginCommand{ + packageManager: packageManager, + commandName: packageManager.String() + "_login", + } +} + +// packageManagerToPackageType maps project types to corresponding Artifactory package types (e.g., npm, pypi). +func packageManagerToPackageType(packageManager project.ProjectType) (string, error) { + switch packageManager { + case project.Npm, project.Yarn: + return repository.Npm, nil + case project.Pip, project.Pipenv, project.Poetry: + return repository.Pypi, nil + case project.Go: + return repository.Go, nil + case project.Nuget, project.Dotnet: + return repository.Nuget, nil + default: + return "", errorutils.CheckErrorf("unsupported package manager: %s", packageManager) + } +} + +// CommandName returns the name of the login command. +func (pmlc *PackageManagerLoginCommand) CommandName() string { + return pmlc.commandName +} + +// SetServerDetails assigns the server configuration details to the command. +func (pmlc *PackageManagerLoginCommand) SetServerDetails(serverDetails *config.ServerDetails) *PackageManagerLoginCommand { + pmlc.serverDetails = serverDetails + return pmlc +} + +// ServerDetails returns the stored server configuration details. +func (pmlc *PackageManagerLoginCommand) ServerDetails() (*config.ServerDetails, error) { + return pmlc.serverDetails, nil +} + +// Run executes the configuration method corresponding to the package manager specified for the command. +func (pmlc *PackageManagerLoginCommand) Run() (err error) { + if pmlc.repoName == "" { + // Prompt the user to select a virtual repository that matches the package manager. + if err = pmlc.promptUserToSelectRepository(); err != nil { + return err + } + } + + // Configure the appropriate package manager based on the package manager. + switch pmlc.packageManager { + case project.Npm: + err = pmlc.configureNpm() + case project.Yarn: + err = pmlc.configureYarn() + case project.Pip, project.Pipenv: + err = pmlc.configurePip() + case project.Poetry: + err = pmlc.configurePoetry() + case project.Go: + err = pmlc.configureGo() + case project.Nuget, project.Dotnet: + err = pmlc.configureDotnetNuget() + default: + err = errorutils.CheckErrorf("unsupported package manager: %s", pmlc.packageManager) + } + if err != nil { + return fmt.Errorf("failed to configure %s: %w", pmlc.packageManager.String(), err) + } + + log.Info(fmt.Sprintf("Successfully configured %s to use JFrog Artifactory repository '%s'.", pmlc.packageManager.String(), pmlc.repoName)) + return nil +} + +// promptUserToSelectRepository prompts the user to select a compatible virtual repository. +func (pmlc *PackageManagerLoginCommand) promptUserToSelectRepository() error { + // Map the package manager to its corresponding package type. + packageType, err := packageManagerToPackageType(pmlc.packageManager) + if err != nil { + return err + } + repoFilterParams := services.RepositoriesFilterParams{ + RepoType: utils.Virtual.String(), + PackageType: packageType, + } + + // Prompt for repository selection based on filter parameters. + pmlc.repoName, err = utils.SelectRepositoryInteractively(pmlc.serverDetails, repoFilterParams) + return err +} + +// configurePip sets the global index-url for pip and pipenv to use the Artifactory PyPI repository. +// Runs the following command: +// +// pip config set global.index-url https://:@/artifactory/api/pypi//simple +func (pmlc *PackageManagerLoginCommand) configurePip() error { + repoWithCredsUrl, err := pythoncommands.GetPypiRepoUrl(pmlc.serverDetails, pmlc.repoName, false) + if err != nil { + return err + } + return pythoncommands.RunConfigCommand(project.Pip, []string{"set", "global.index-url", repoWithCredsUrl}) +} + +// configurePoetry configures Poetry to use the specified repository and authentication credentials. +// Runs the following commands: +// +// poetry config repositories. https:///artifactory/api/pypi//simple +// poetry config http-basic. +func (pmlc *PackageManagerLoginCommand) configurePoetry() error { + repoUrl, username, password, err := pythoncommands.GetPypiRepoUrlWithCredentials(pmlc.serverDetails, pmlc.repoName, false) + if err != nil { + return err + } + return pythoncommands.RunPoetryConfig(repoUrl.String(), username, password, pmlc.repoName) +} + +// configureNpm configures npm to use the Artifactory repository URL and sets authentication. +// Runs the following commands: +// +// npm config set registry https:///artifactory/api/npm/ +// +// For token-based auth: +// +// npm config set //your-artifactory-url/artifactory/api/npm//:_authToken "" +// +// For basic auth: +// +// npm config set //your-artifactory-url/artifactory/api/npm//:_auth "" +func (pmlc *PackageManagerLoginCommand) configureNpm() error { + repoUrl := commandsutils.GetNpmRepositoryUrl(pmlc.repoName, pmlc.serverDetails.ArtifactoryUrl) + + if err := npm.ConfigSet(commandsutils.NpmConfigRegistryKey, repoUrl, "npm"); err != nil { + return err + } + + authKey, authValue := commandsutils.GetNpmAuthKeyValue(pmlc.serverDetails, repoUrl) + if authKey != "" && authValue != "" { + return npm.ConfigSet(authKey, authValue, "npm") + } + return nil +} + +// configureYarn configures Yarn to use the specified Artifactory repository and sets authentication. +// Runs the following commands: +// +// yarn config set registry https:///artifactory/api/npm/ +// +// For token-based auth: +// +// yarn config set //your-artifactory-url/artifactory/api/npm//:_authToken "" +// +// For basic auth: +// +// yarn config set //your-artifactory-url/artifactory/api/npm//:_auth "" +func (pmlc *PackageManagerLoginCommand) configureYarn() error { + repoUrl := commandsutils.GetNpmRepositoryUrl(pmlc.repoName, pmlc.serverDetails.ArtifactoryUrl) + + if err := yarn.ConfigSet(commandsutils.NpmConfigRegistryKey, repoUrl, "yarn", false); err != nil { + return err + } + + authKey, authValue := commandsutils.GetNpmAuthKeyValue(pmlc.serverDetails, repoUrl) + if authKey != "" && authValue != "" { + return yarn.ConfigSet(authKey, authValue, "yarn", false) + } + return nil +} + +// configureGo configures Go to use the Artifactory repository for GOPROXY. +// Runs the following command: +// +// go env -w GOPROXY=https://:@/artifactory/go/,direct +func (pmlc *PackageManagerLoginCommand) configureGo() error { + repoWithCredsUrl, err := gocommands.GetArtifactoryRemoteRepoUrl(pmlc.serverDetails, pmlc.repoName, gocommands.GoProxyUrlParams{Direct: true}) + if err != nil { + return err + } + return biutils.RunGo([]string{"env", "-w", "GOPROXY=" + repoWithCredsUrl}, "") +} + +// configureDotnetNuget configures NuGet or .NET Core to use the specified Artifactory repository with credentials. +// Adds the repository source to the NuGet configuration file, using appropriate credentials for authentication. +// The following command is run for dotnet: +// +// dotnet nuget add source --name "https://acme.jfrog.io/artifactory/api/nuget/{repository-name}" --username --password +// +// For NuGet: +// +// nuget sources add -Name -Source "https://acme.jfrog.io/artifactory/api/nuget/{repository-name}" -Username -Password +func (pmlc *PackageManagerLoginCommand) configureDotnetNuget() error { + // Retrieve repository URL and credentials for NuGet or .NET Core. + sourceUrl, user, password, err := dotnet.GetSourceDetails(pmlc.serverDetails, pmlc.repoName, false) + if err != nil { + return err + } + + // Determine the appropriate toolchain type (NuGet or .NET Core). + toolchainType := bidotnet.DotnetCore + if pmlc.packageManager == project.Nuget { + toolchainType = bidotnet.Nuget + } + if err = dotnet.RemoveSourceFromNugetConfigIfExists(toolchainType); err != nil { + return err + } + // Add the repository as a source in the NuGet configuration with credentials for authentication. + return dotnet.AddSourceToNugetConfig(toolchainType, sourceUrl, user, password) +} diff --git a/artifactory/commands/packagemanagerlogin/packagemanagerlogin_test.go b/artifactory/commands/packagemanagerlogin/packagemanagerlogin_test.go new file mode 100644 index 000000000..8bb95abde --- /dev/null +++ b/artifactory/commands/packagemanagerlogin/packagemanagerlogin_test.go @@ -0,0 +1,383 @@ +package packagemanagerlogin + +import ( + "fmt" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/dotnet" + cmdutils "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/utils" + "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/io" + clientTestUtils "github.com/jfrog/jfrog-client-go/utils/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// #nosec G101 -- Dummy token for tests +var dummyToken = "eyJ2ZXIiOiIyIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYiLCJraWQiOiJIcnU2VHctZk1yOTV3dy12TDNjV3ZBVjJ3Qm9FSHpHdGlwUEFwOE1JdDljIn0.eyJzdWIiOiJqZnJ0QDAxYzNnZmZoZzJlOHc2MTQ5ZTNhMnEwdzk3XC91c2Vyc1wvYWRtaW4iLCJzY3AiOiJtZW1iZXItb2YtZ3JvdXBzOnJlYWRlcnMgYXBpOioiLCJhdWQiOiJqZnJ0QDAxYzNnZmZoZzJlOHc2MTQ5ZTNhMnEwdzk3IiwiaXNzIjoiamZydEAwMWMzZ2ZmaGcyZTh3NjE0OWUzYTJxMHc5NyIsImV4cCI6MTU1NjAzNzc2NSwiaWF0IjoxNTU2MDM0MTY1LCJqdGkiOiI1M2FlMzgyMy05NGM3LTQ0OGItOGExOC1iZGVhNDBiZjFlMjAifQ.Bp3sdvppvRxysMlLgqT48nRIHXISj9sJUCXrm7pp8evJGZW1S9hFuK1olPmcSybk2HNzdzoMcwhUmdUzAssiQkQvqd_HanRcfFbrHeg5l1fUQ397ECES-r5xK18SYtG1VR7LNTVzhJqkmRd3jzqfmIK2hKWpEgPfm8DRz3j4GGtDRxhb3oaVsT2tSSi_VfT3Ry74tzmO0GcCvmBE2oh58kUZ4QfEsalgZ8IpYHTxovsgDx_M7ujOSZx_hzpz-iy268-OkrU22PQPCfBmlbEKeEUStUO9n0pj4l1ODL31AGARyJRy46w4yzhw7Fk5P336WmDMXYs5LAX2XxPFNLvNzA" + +var testCases = []struct { + name string + user string + password string + accessToken string +}{ + { + name: "Token Authentication", + accessToken: dummyToken, + }, + { + name: "Basic Authentication", + user: "myUser", + password: "myPassword", + }, + { + name: "Anonymous Access", + }, +} + +func createTestPackageManagerLoginCommand(packageManager project.ProjectType) *PackageManagerLoginCommand { + cmd := NewPackageManagerLoginCommand(packageManager) + cmd.repoName = "test-repo" + dummyUrl := "https://acme.jfrog.io" + cmd.serverDetails = &config.ServerDetails{Url: dummyUrl, ArtifactoryUrl: dummyUrl + "/artifactory"} + + return cmd +} + +func TestPackageManagerLoginCommand_Npm(t *testing.T) { + // Create a temporary directory to act as the environment's npmrc file location. + tempDir := t.TempDir() + npmrcFilePath := filepath.Join(tempDir, ".npmrc") + + // Set NPM_CONFIG_USERCONFIG to point to the temporary npmrc file path. + t.Setenv("NPM_CONFIG_USERCONFIG", npmrcFilePath) + + npmLoginCmd := createTestPackageManagerLoginCommand(project.Npm) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + npmLoginCmd.serverDetails.SetUser(testCase.user) + npmLoginCmd.serverDetails.SetPassword(testCase.password) + npmLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, npmLoginCmd.Run()) + + // Read the contents of the temporary npmrc file. + npmrcContentBytes, err := os.ReadFile(npmrcFilePath) + assert.NoError(t, err) + npmrcContent := string(npmrcContentBytes) + + // Validate that the registry URL was set correctly in .npmrc. + assert.Contains(t, npmrcContent, fmt.Sprintf("%s=%s", cmdutils.NpmConfigRegistryKey, "https://acme.jfrog.io/artifactory/api/npm/test-repo")) + + // Validate token-based authentication. + if testCase.accessToken != "" { + assert.Contains(t, npmrcContent, fmt.Sprintf("//acme.jfrog.io/artifactory/api/npm/test-repo:%s=%s", cmdutils.NpmConfigAuthTokenKey, dummyToken)) + } else if testCase.user != "" && testCase.password != "" { + // Validate basic authentication with encoded credentials. + // Base64 encoding of "myUser:myPassword" + expectedBasicAuth := fmt.Sprintf("//acme.jfrog.io/artifactory/api/npm/test-repo:%s=\"bXlVc2VyOm15UGFzc3dvcmQ=\"", cmdutils.NpmConfigAuthKey) + assert.Contains(t, npmrcContent, expectedBasicAuth) + } + + // Clean up the temporary npmrc file. + assert.NoError(t, os.Remove(npmrcFilePath)) + }) + } +} + +func TestPackageManagerLoginCommand_Yarn(t *testing.T) { + // Retrieve the home directory and construct the .yarnrc file path. + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + yarnrcFilePath := filepath.Join(homeDir, ".yarnrc") + + // Back up the existing .yarnrc file and ensure restoration after the test. + restoreYarnrcFunc, err := ioutils.BackupFile(yarnrcFilePath, ".yarnrc.backup") + assert.NoError(t, err) + defer func() { + assert.NoError(t, restoreYarnrcFunc()) + }() + + yarnLoginCmd := createTestPackageManagerLoginCommand(project.Yarn) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + yarnLoginCmd.serverDetails.SetUser(testCase.user) + yarnLoginCmd.serverDetails.SetPassword(testCase.password) + yarnLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, yarnLoginCmd.Run()) + + // Read the contents of the temporary npmrc file. + yarnrcContentBytes, err := os.ReadFile(yarnrcFilePath) + assert.NoError(t, err) + yarnrcContent := string(yarnrcContentBytes) + + // Check that the registry URL is correctly set in .yarnrc. + assert.Contains(t, yarnrcContent, fmt.Sprintf("%s \"%s\"", cmdutils.NpmConfigRegistryKey, "https://acme.jfrog.io/artifactory/api/npm/test-repo")) + + // Validate token-based authentication. + if testCase.accessToken != "" { + assert.Contains(t, yarnrcContent, fmt.Sprintf("\"//acme.jfrog.io/artifactory/api/npm/test-repo:%s\" %s", cmdutils.NpmConfigAuthTokenKey, dummyToken)) + + } else if testCase.user != "" && testCase.password != "" { + // Validate basic authentication with encoded credentials. + // Base64 encoding of "myUser:myPassword" + assert.Contains(t, yarnrcContent, fmt.Sprintf("\"//acme.jfrog.io/artifactory/api/npm/test-repo:%s\" bXlVc2VyOm15UGFzc3dvcmQ=", cmdutils.NpmConfigAuthKey)) + } + + // Clean up the temporary npmrc file. + assert.NoError(t, os.Remove(yarnrcFilePath)) + }) + } +} + +func TestPackageManagerLoginCommand_Pip(t *testing.T) { + // pip and pipenv share the same configuration file. + testPackageManagerLoginCommandPip(t, project.Pip) +} + +func TestPackageManagerLoginCommand_Pipenv(t *testing.T) { + // pip and pipenv share the same configuration file. + testPackageManagerLoginCommandPip(t, project.Pipenv) +} + +func testPackageManagerLoginCommandPip(t *testing.T, packageManager project.ProjectType) { + var pipConfFilePath string + if coreutils.IsWindows() { + pipConfFilePath = filepath.Join(os.Getenv("APPDATA"), "pip", "pip.ini") + } else { + // Retrieve the home directory and construct the pip.conf file path. + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + pipConfFilePath = filepath.Join(homeDir, ".config", "pip", "pip.conf") + } + // Back up the existing .pip.conf file and ensure restoration after the test. + restorePipConfFunc, err := ioutils.BackupFile(pipConfFilePath, ".pipconf.backup") + assert.NoError(t, err) + defer func() { + assert.NoError(t, restorePipConfFunc()) + }() + + pipLoginCmd := createTestPackageManagerLoginCommand(packageManager) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + pipLoginCmd.serverDetails.SetUser(testCase.user) + pipLoginCmd.serverDetails.SetPassword(testCase.password) + pipLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, pipLoginCmd.Run()) + + // Read the contents of the temporary pip config file. + pipConfigContentBytes, err := os.ReadFile(pipConfFilePath) + assert.NoError(t, err) + pipConfigContent := string(pipConfigContentBytes) + + switch { + case testCase.accessToken != "": + // Validate token-based authentication. + assert.Contains(t, pipConfigContent, fmt.Sprintf("index-url = https://%s:%s@acme.jfrog.io/artifactory/api/pypi/test-repo/simple", auth.ExtractUsernameFromAccessToken(testCase.accessToken), testCase.accessToken)) + case testCase.user != "" && testCase.password != "": + // Validate basic authentication with user and password. + assert.Contains(t, pipConfigContent, fmt.Sprintf("index-url = https://%s:%s@acme.jfrog.io/artifactory/api/pypi/test-repo/simple", "myUser", "myPassword")) + default: + // Validate anonymous access. + assert.Contains(t, pipConfigContent, "index-url = https://acme.jfrog.io/artifactory/api/pypi/test-repo/simple") + } + + // Clean up the temporary pip config file. + assert.NoError(t, os.Remove(pipConfFilePath)) + }) + } +} + +func TestPackageManagerLoginCommand_configurePoetry(t *testing.T) { + // Retrieve the home directory and construct the .yarnrc file path. + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + var poetryConfigDir string + switch { + case io.IsWindows(): + poetryConfigDir = filepath.Join(homeDir, "AppData", "Roaming") + case io.IsMacOS(): + poetryConfigDir = filepath.Join(homeDir, "Library", "Application Support") + default: + poetryConfigDir = filepath.Join(homeDir, ".config") + } + + poetryConfigFilePath := filepath.Join(poetryConfigDir, "pypoetry", "config.toml") + // Poetry stores the auth in a separate file + poetryAuthFilePath := filepath.Join(poetryConfigDir, "pypoetry", "auth.toml") + + // Back up the existing config.toml and auth.toml files and ensure restoration after the test. + restorePoetryConfigFunc, err := ioutils.BackupFile(poetryConfigFilePath, ".poetry.config.backup") + assert.NoError(t, err) + restorePoetryAuthFunc, err := ioutils.BackupFile(poetryAuthFilePath, ".poetry-auth.backup") + assert.NoError(t, err) + defer func() { + assert.NoError(t, restorePoetryConfigFunc()) + assert.NoError(t, restorePoetryAuthFunc()) + }() + + poetryLoginCmd := createTestPackageManagerLoginCommand(project.Poetry) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + poetryLoginCmd.serverDetails.SetUser(testCase.user) + poetryLoginCmd.serverDetails.SetPassword(testCase.password) + poetryLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, poetryLoginCmd.Run()) + + // Validate that the repository URL was set correctly in config.toml. + // Read the contents of the temporary Poetry config file. + poetryConfigContentBytes, err := os.ReadFile(poetryConfigFilePath) + assert.NoError(t, err) + poetryConfigContent := string(poetryConfigContentBytes) + // Normalize line endings for comparison.(For Windows) + poetryConfigContent = strings.ReplaceAll(poetryConfigContent, "\r\n", "\n") + + assert.Contains(t, poetryConfigContent, "[repositories.test-repo]\nurl = \"https://acme.jfrog.io/artifactory/api/pypi/test-repo/simple\"") + + // Validate that the auth details were set correctly in auth.toml. + // Read the contents of the temporary Poetry config file. + poetryAuthContentBytes, err := os.ReadFile(poetryAuthFilePath) + assert.NoError(t, err) + poetryAuthContent := string(poetryAuthContentBytes) + // Normalize line endings for comparison.(For Windows) + poetryAuthContent = strings.ReplaceAll(poetryAuthContent, "\r\n", "\n") + + if testCase.accessToken != "" { + // Validate token-based authentication (The token is stored in the keyring so we can't test it) + assert.Contains(t, poetryAuthContent, fmt.Sprintf("[http-basic.test-repo]\nusername = \"%s\"", auth.ExtractUsernameFromAccessToken(testCase.accessToken))) + } else if testCase.user != "" && testCase.password != "" { + // Validate basic authentication with user and password. (The password is stored in the keyring so we can't test it) + assert.Contains(t, poetryAuthContent, fmt.Sprintf("[http-basic.test-repo]\nusername = \"%s\"", "myUser")) + } + + // Clean up the temporary Poetry config files. + assert.NoError(t, os.Remove(poetryConfigFilePath)) + assert.NoError(t, os.Remove(poetryAuthFilePath)) + }) + } +} + +func TestPackageManagerLoginCommand_Go(t *testing.T) { + goProxyEnv := "GOPROXY" + // Restore the original value of the GOPROXY environment variable after the test. + restoreGoProxy := clientTestUtils.SetEnvWithCallbackAndAssert(t, goProxyEnv, "") + defer restoreGoProxy() + + // Assuming createTestPackageManagerLoginCommand initializes your Go login command + goLoginCmd := createTestPackageManagerLoginCommand(project.Go) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + goLoginCmd.serverDetails.SetUser(testCase.user) + goLoginCmd.serverDetails.SetPassword(testCase.password) + goLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, goLoginCmd.Run()) + + // Get the value of the GOPROXY environment variable. + outputBytes, err := exec.Command("go", "env", "GOPROXY").Output() + assert.NoError(t, err) + goProxy := string(outputBytes) + + switch { + case testCase.accessToken != "": + // Validate token-based authentication. + assert.Contains(t, goProxy, fmt.Sprintf("https://%s:%s@acme.jfrog.io/artifactory/api/go/test-repo", auth.ExtractUsernameFromAccessToken(testCase.accessToken), testCase.accessToken)) + case testCase.user != "" && testCase.password != "": + // Validate basic authentication with user and password. + assert.Contains(t, goProxy, fmt.Sprintf("https://%s:%s@acme.jfrog.io/artifactory/api/go/test-repo", "myUser", "myPassword")) + default: + // Validate anonymous access. + assert.Contains(t, goProxy, "https://acme.jfrog.io/artifactory/api/go/test-repo") + } + }) + } +} + +func TestBuildToolLoginCommand_configureNuget(t *testing.T) { + testBuildToolLoginCommandConfigureDotnetNuget(t, project.Nuget) +} + +func TestBuildToolLoginCommand_configureDotnet(t *testing.T) { + testBuildToolLoginCommandConfigureDotnetNuget(t, project.Dotnet) +} + +func testBuildToolLoginCommandConfigureDotnetNuget(t *testing.T, packageManager project.ProjectType) { + // Retrieve the home directory and construct the NuGet.config file path. + homeDir, err := os.UserHomeDir() + assert.NoError(t, err) + var nugetConfigDir string + switch { + case io.IsWindows(): + nugetConfigDir = filepath.Join("AppData", "Roaming") + case packageManager == project.Nuget: + nugetConfigDir = ".config" + default: + nugetConfigDir = ".nuget" + } + + nugetConfigFilePath := filepath.Join(homeDir, nugetConfigDir, "NuGet", "NuGet.Config") + + // Back up the existing NuGet.config and ensure restoration after the test. + restoreNugetConfigFunc, err := ioutils.BackupFile(nugetConfigFilePath, packageManager.String()+".config.backup") + assert.NoError(t, err) + defer func() { + assert.NoError(t, restoreNugetConfigFunc()) + }() + + nugetLoginCmd := createTestPackageManagerLoginCommand(packageManager) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + // Set up server details for the current test case's authentication type. + nugetLoginCmd.serverDetails.SetUser(testCase.user) + nugetLoginCmd.serverDetails.SetPassword(testCase.password) + nugetLoginCmd.serverDetails.SetAccessToken(testCase.accessToken) + + // Run the login command and ensure no errors occur. + require.NoError(t, nugetLoginCmd.Run()) + + // Validate that the repository URL was set correctly in Nuget.config. + // Read the contents of the temporary Poetry config file. + nugetConfigContentBytes, err := os.ReadFile(nugetConfigFilePath) + require.NoError(t, err) + + nugetConfigContent := string(nugetConfigContentBytes) + + assert.Contains(t, nugetConfigContent, fmt.Sprintf("add key=\"%s\" value=\"https://acme.jfrog.io/artifactory/api/nuget/v3/test-repo\"", dotnet.SourceName)) + + if testCase.accessToken != "" { + // Validate token-based authentication (The token is encoded so we can't test it) + assert.Contains(t, nugetConfigContent, fmt.Sprintf("", auth.ExtractUsernameFromAccessToken(testCase.accessToken))) + } else if testCase.user != "" && testCase.password != "" { + // Validate basic nugetConfigContent with user and password. (The password is encoded so we can't test it) + assert.Contains(t, nugetConfigContent, fmt.Sprintf("", testCase.user)) + } + }) + } +} diff --git a/artifactory/commands/python/poetry.go b/artifactory/commands/python/poetry.go index 269935637..ec362653e 100644 --- a/artifactory/commands/python/poetry.go +++ b/artifactory/commands/python/poetry.go @@ -10,12 +10,22 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/python/dependencies" "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/project" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/spf13/viper" "golang.org/x/exp/slices" "io" + "os" "os/exec" + "path/filepath" +) + +const ( + poetryConfigAuthPrefix = "http-basic." + poetryConfigRepoPrefix = "repositories." + pyproject = "pyproject.toml" ) type PoetryCommand struct { @@ -134,6 +144,61 @@ func (pc *PoetryCommand) SetPypiRepoUrlWithCredentials() error { return nil } +func ConfigPoetryRepo(url, username, password, configRepoName string) error { + err := RunPoetryConfig(url, username, password, configRepoName) + if err != nil { + return err + } + + // Add the repository config to the pyproject.toml + currentDir, err := os.Getwd() + if err != nil { + return errorutils.CheckError(err) + } + if err = addRepoToPyprojectFile(filepath.Join(currentDir, pyproject), configRepoName, url); err != nil { + return err + } + return poetryUpdate() +} + +func RunPoetryConfig(url, username, password, configRepoName string) error { + // Add the poetry repository config + // poetry config repositories. https:///artifactory/api/pypi//simple + err := RunConfigCommand(project.Poetry, []string{poetryConfigRepoPrefix + configRepoName, url}) + if err != nil { + return err + } + + // Set the poetry repository credentials + // poetry config http-basic. + return RunConfigCommand(project.Poetry, []string{poetryConfigAuthPrefix + configRepoName, username, password}) +} + +func poetryUpdate() (err error) { + log.Info("Running Poetry update") + cmd := gofrogcmd.NewCommand("poetry", "update", []string{}) + err = gofrogcmd.RunCmd(cmd) + if err != nil { + return errorutils.CheckErrorf("Poetry config command failed with: %s", err.Error()) + } + return +} + +func addRepoToPyprojectFile(filepath, poetryRepoName, repoUrl string) error { + viper.SetConfigType("toml") + viper.SetConfigFile(filepath) + if err := viper.ReadInConfig(); err != nil { + return errorutils.CheckErrorf("Failed to read pyproject.toml: %s", err.Error()) + } + viper.Set("tool.poetry.source", []map[string]string{{"name": poetryRepoName, "url": repoUrl}}) + if err := viper.WriteConfig(); err != nil { + return errorutils.CheckErrorf("Failed to add tool.poetry.source to pyproject.toml: %s", err.Error()) + + } + log.Info(fmt.Sprintf("Added tool.poetry.source name:%q url:%q", poetryRepoName, repoUrl)) + return nil +} + func (pc *PoetryCommand) CommandName() string { return "rt_python_poetry" } diff --git a/artifactory/commands/python/poetry_test.go b/artifactory/commands/python/poetry_test.go new file mode 100644 index 000000000..48cd16cd8 --- /dev/null +++ b/artifactory/commands/python/poetry_test.go @@ -0,0 +1,32 @@ +package python + +import ( + "github.com/jfrog/jfrog-cli-core/v2/utils/tests" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/stretchr/testify/assert" + "path/filepath" + "testing" +) + +func TestAddRepoToPyprojectFile(t *testing.T) { + poetryProjectPath, cleanUp := initPoetryTest(t) + defer cleanUp() + pyProjectPath := filepath.Join(poetryProjectPath, "pyproject.toml") + dummyRepoName := "test-repo-name" + dummyRepoURL := "https://ecosysjfrog.jfrog.io/" + + err := addRepoToPyprojectFile(pyProjectPath, dummyRepoName, dummyRepoURL) + assert.NoError(t, err) + // Validate pyproject.toml file content + content, err := fileutils.ReadFile(pyProjectPath) + assert.NoError(t, err) + assert.Contains(t, string(content), dummyRepoURL) +} + +func initPoetryTest(t *testing.T) (string, func()) { + // Create and change directory to test workspace + testAbs, err := filepath.Abs(filepath.Join("..", "..", "..", "tests", "testdata", "poetry-project")) + assert.NoError(t, err) + poetryProjectPath, cleanUp := tests.CreateTestWorkspace(t, testAbs) + return poetryProjectPath, cleanUp +} diff --git a/artifactory/commands/python/python.go b/artifactory/commands/python/python.go index 238ea074a..3a2a94709 100644 --- a/artifactory/commands/python/python.go +++ b/artifactory/commands/python/python.go @@ -3,7 +3,6 @@ package python import ( "bytes" "errors" - "fmt" "github.com/jfrog/build-info-go/build" "github.com/jfrog/build-info-go/entities" buildInfoUtils "github.com/jfrog/build-info-go/utils" @@ -12,26 +11,21 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/artifactory/commands/python/dependencies" "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/project" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "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" "io" "net/url" "os" "os/exec" - "path/filepath" - "strings" ) const ( pipenvRemoteRegistryFlag = "--pypi-mirror" pipRemoteRegistryFlag = "-i" - poetryConfigAuthPrefix = "http-basic." - poetryConfigRepoPrefix = "repositories." - pyproject = "pyproject.toml" ) type PythonCommand struct { @@ -141,35 +135,7 @@ func (pc *PythonCommand) SetPypiRepoUrlWithCredentials() error { return nil } -func (pc *PythonCommand) SetServerDetails(serverDetails *config.ServerDetails) *PythonCommand { - pc.serverDetails = serverDetails - return pc -} - -func (pc *PythonCommand) ServerDetails() (*config.ServerDetails, error) { - return pc.serverDetails, nil -} - -func (pc *PythonCommand) GetCmd() *exec.Cmd { - var cmd []string - cmd = append(cmd, string(pc.pythonTool)) - cmd = append(cmd, pc.commandName) - cmd = append(cmd, pc.args...) - return exec.Command(cmd[0], cmd[1:]...) -} - -func (pc *PythonCommand) GetEnv() map[string]string { - return map[string]string{} -} - -func (pc *PythonCommand) GetStdWriter() io.WriteCloser { - return nil -} - -func (pc *PythonCommand) GetErrWriter() io.WriteCloser { - return nil -} - +// Get the pypi repository url and the credentials. func GetPypiRepoUrlWithCredentials(serverDetails *config.ServerDetails, repository string, isCurationCmd bool) (*url.URL, string, string, error) { rtUrl, err := url.Parse(serverDetails.GetArtifactoryUrl()) if err != nil { @@ -186,11 +152,10 @@ func GetPypiRepoUrlWithCredentials(serverDetails *config.ServerDetails, reposito } password = serverDetails.GetAccessToken() } - // In case of curation command, the download urls should be routed through a dedicated api. if isCurationCmd { - rtUrl.Path += coreutils.CurationPassThroughApi + rtUrl = rtUrl.JoinPath(coreutils.CurationPassThroughApi) } - rtUrl.Path += "api/pypi/" + repository + "/simple" + rtUrl = rtUrl.JoinPath("api/pypi", repository, "simple") return rtUrl, username, password, err } @@ -201,6 +166,7 @@ func GetPypiRemoteRegistryFlag(tool pythonutils.PythonTool) string { return pipenvRemoteRegistryFlag } +// Get the pypi repository embedded credentials URL (https://:@/artifactory/api/pypi//simple) func GetPypiRepoUrl(serverDetails *config.ServerDetails, repository string, isCurationCmd bool) (string, error) { rtUrl, username, password, err := GetPypiRepoUrlWithCredentials(serverDetails, repository, isCurationCmd) if err != nil { @@ -212,68 +178,40 @@ func GetPypiRepoUrl(serverDetails *config.ServerDetails, repository string, isCu return rtUrl.String(), err } -func ConfigPoetryRepo(url, username, password, configRepoName string) error { - // Add the poetry repository config - err := runPoetryConfigCommand([]string{poetryConfigRepoPrefix + configRepoName, url}, false) - if err != nil { - return err +func RunConfigCommand(buildTool project.ProjectType, args []string) error { + log.Debug("Running", buildTool.String(), "config command...") + configCmd := gofrogcmd.NewCommand(buildTool.String(), "config", args) + if err := gofrogcmd.RunCmd(configCmd); err != nil { + return errorutils.CheckErrorf("%s config command failed with: %q", buildTool.String(), err) } + return nil +} - // Set the poetry repository credentials - err = runPoetryConfigCommand([]string{poetryConfigAuthPrefix + configRepoName, username, password}, true) - if err != nil { - return err - } +func (pc *PythonCommand) SetServerDetails(serverDetails *config.ServerDetails) *PythonCommand { + pc.serverDetails = serverDetails + return pc +} - // Add the repository config to the pyproject.toml - currentDir, err := os.Getwd() - if err != nil { - return errorutils.CheckError(err) - } - if err = addRepoToPyprojectFile(filepath.Join(currentDir, pyproject), configRepoName, url); err != nil { - return err - } - return poetryUpdate() +func (pc *PythonCommand) ServerDetails() (*config.ServerDetails, error) { + return pc.serverDetails, nil } -func poetryUpdate() (err error) { - log.Info("Running Poetry update") - cmd := gofrogcmd.NewCommand("poetry", "update", []string{}) - err = gofrogcmd.RunCmd(cmd) - if err != nil { - return errorutils.CheckErrorf("Poetry config command failed with: %s", err.Error()) - } - return +func (pc *PythonCommand) GetCmd() *exec.Cmd { + var cmd []string + cmd = append(cmd, string(pc.pythonTool)) + cmd = append(cmd, pc.commandName) + cmd = append(cmd, pc.args...) + return exec.Command(cmd[0], cmd[1:]...) } -func runPoetryConfigCommand(args []string, maskArgs bool) error { - logMessage := "config " - if maskArgs { - logMessage += "***" - } else { - logMessage += strings.Join(args, " ") - } - log.Info(fmt.Sprintf("Running Poetry %s", logMessage)) - cmd := gofrogcmd.NewCommand("poetry", "config", args) - err := gofrogcmd.RunCmd(cmd) - if err != nil { - return errorutils.CheckErrorf("Poetry config command failed with: %s", err.Error()) - } +func (pc *PythonCommand) GetEnv() map[string]string { + return map[string]string{} +} + +func (pc *PythonCommand) GetStdWriter() io.WriteCloser { return nil } -func addRepoToPyprojectFile(filepath, poetryRepoName, repoUrl string) error { - viper.SetConfigType("toml") - viper.SetConfigFile(filepath) - err := viper.ReadInConfig() - if err != nil { - return errorutils.CheckErrorf("Failed to read pyproject.toml: %s", err.Error()) - } - viper.Set("tool.poetry.source", []map[string]string{{"name": poetryRepoName, "url": repoUrl}}) - err = viper.WriteConfig() - if err != nil { - return errorutils.CheckErrorf("Failed to add tool.poetry.source to pyproject.toml: %s", err.Error()) - } - log.Info(fmt.Sprintf("Added tool.poetry.source name:%q url:%q", poetryRepoName, repoUrl)) - return err +func (pc *PythonCommand) GetErrWriter() io.WriteCloser { + return nil } diff --git a/artifactory/commands/python/python_test.go b/artifactory/commands/python/python_test.go new file mode 100644 index 000000000..2b25468c9 --- /dev/null +++ b/artifactory/commands/python/python_test.go @@ -0,0 +1,34 @@ +package python + +import ( + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "strings" + "testing" +) + +func TestGetPypiRepoUrlWithCredentials(t *testing.T) { + tests := []struct { + name string + curationCmd bool + }{ + { + name: "test curation command true", + curationCmd: true, + }, + { + name: "test curation command false", + curationCmd: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + url, _, _, err := GetPypiRepoUrlWithCredentials(&config.ServerDetails{}, "test", tt.curationCmd) + require.NoError(t, err) + assert.Equal(t, tt.curationCmd, strings.Contains(url.Path, coreutils.CurationPassThroughApi)) + }) + } +} diff --git a/artifactory/commands/utils/npmcmdutils.go b/artifactory/commands/utils/npmcmdutils.go index d7c5c760e..2c4fe43ac 100644 --- a/artifactory/commands/utils/npmcmdutils.go +++ b/artifactory/commands/utils/npmcmdutils.go @@ -1,26 +1,30 @@ package utils import ( + "encoding/base64" "fmt" - outFormat "github.com/jfrog/jfrog-cli-core/v2/common/format" - "net/http" - "strings" - "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" "github.com/jfrog/jfrog-cli-core/v2/common/build" + outFormat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/auth" "github.com/jfrog/jfrog-client-go/http/httpclient" clientutils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" + "net/http" + "strings" ) const ( minSupportedArtifactoryVersionForNpmCmds = "5.5.2" - NpmConfigAuthKey = "_auth" - NpmConfigAuthTokenKey = "_authToken" - npmAuthRestApi = "api/npm/auth" + + NpmConfigAuthKey = "_auth" + // Supported only in npm version 9 and above. + NpmConfigAuthTokenKey = "_authToken" + NpmConfigRegistryKey = "registry" + npmAuthRestApi = "api/npm/auth" ) // Constructs npm auth config and registry, manually or by requesting the Artifactory /npm/auth endpoint. @@ -37,7 +41,7 @@ func GetArtifactoryNpmRepoDetails(repo string, authArtDetails auth.ServiceDetail return "", "", err } - registry = getNpmRepositoryUrl(repo, authArtDetails.GetUrl()) + registry = GetNpmRepositoryUrl(repo, authArtDetails.GetUrl()) return } @@ -59,7 +63,7 @@ func getNpmAuth(authArtDetails auth.ServiceDetails, isNpmAuthLegacyVersion bool) // Manually constructs the npm authToken config data. func constructNpmAuthToken(token string) string { - return fmt.Sprintf("%s = %s\nalways-auth = true", NpmConfigAuthTokenKey, token) + return fmt.Sprintf("%s = %s", NpmConfigAuthTokenKey, token) } func validateArtifactoryVersionForNpmCmds(artDetails auth.ServiceDetails) error { @@ -93,12 +97,30 @@ func getNpmAuthFromArtifactory(artDetails auth.ServiceDetails) (npmAuth string, return string(body), nil } -func getNpmRepositoryUrl(repo, url string) string { - if !strings.HasSuffix(url, "/") { - url += "/" +func GetNpmRepositoryUrl(repositoryName, artifactoryUrl string) string { + return strings.TrimSuffix(artifactoryUrl, "/") + "/api/npm/" + repositoryName +} + +// GetNpmAuthKeyValue generates the correct authentication key and value for npm or Yarn, based on the repo URL. +func GetNpmAuthKeyValue(serverDetails *config.ServerDetails, repoUrl string) (key, value string) { + var keySuffix string + switch { + case serverDetails.GetAccessToken() != "": + keySuffix = NpmConfigAuthTokenKey + value = serverDetails.GetAccessToken() + case serverDetails.GetUser() != "" && serverDetails.GetPassword() != "": + keySuffix = NpmConfigAuthKey + value = basicAuthBase64Encode(serverDetails.GetUser(), serverDetails.GetPassword()) + default: + return "", "" } - url += "api/npm/" + repo - return url + + return fmt.Sprintf("//%s:%s", strings.TrimPrefix(repoUrl, "https://"), keySuffix), value +} + +// basicAuthBase64Encode encodes user credentials in Base64 for basic authentication. +func basicAuthBase64Encode(user, password string) string { + return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, password))) } // Remove all the none npm CLI flags from args. diff --git a/artifactory/commands/utils/npmcmdutils_test.go b/artifactory/commands/utils/npmcmdutils_test.go index 30469b071..3dc7cf8a7 100644 --- a/artifactory/commands/utils/npmcmdutils_test.go +++ b/artifactory/commands/utils/npmcmdutils_test.go @@ -21,8 +21,8 @@ func TestGetRegistry(t *testing.T) { } for _, testCase := range getRegistryTest { - if getNpmRepositoryUrl(testCase.repo, testCase.url) != testCase.expected { - t.Errorf("The expected output of getRegistry(\"%s\", \"%s\") is %s. But the actual result is:%s", testCase.repo, testCase.url, testCase.expected, getNpmRepositoryUrl(testCase.repo, testCase.url)) + if GetNpmRepositoryUrl(testCase.repo, testCase.url) != testCase.expected { + t.Errorf("The expected output of getRegistry(\"%s\", \"%s\") is %s. But the actual result is:%s", testCase.repo, testCase.url, testCase.expected, GetNpmRepositoryUrl(testCase.repo, testCase.url)) } } } diff --git a/artifactory/utils/npm/config-get.go b/artifactory/utils/npm/configget.go similarity index 88% rename from artifactory/utils/npm/config-get.go rename to artifactory/utils/npm/configget.go index 5fff4649f..876182b82 100644 --- a/artifactory/utils/npm/config-get.go +++ b/artifactory/utils/npm/configget.go @@ -7,6 +7,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" ) +// This method runs "npm config get" command and returns the value of the specified npm configuration. func ConfigGet(npmFlags []string, confName, executablePath string) (string, error) { configGetCmdConfig := createConfigGetCmdConfig(executablePath, confName, npmFlags) output, err := gofrogcmd.RunCmdOutput(configGetCmdConfig) diff --git a/artifactory/utils/npm/config-list.go b/artifactory/utils/npm/configlist.go similarity index 100% rename from artifactory/utils/npm/config-list.go rename to artifactory/utils/npm/configlist.go diff --git a/artifactory/utils/npm/configset.go b/artifactory/utils/npm/configset.go new file mode 100644 index 000000000..bcdfbcb48 --- /dev/null +++ b/artifactory/utils/npm/configset.go @@ -0,0 +1,25 @@ +package npm + +import ( + gofrogcmd "github.com/jfrog/gofrog/io" + "github.com/jfrog/jfrog-client-go/utils/errorutils" +) + +// This method runs "npm config set" command and sets the npm configuration. +func ConfigSet(key, value, executablePath string) error { + configGetCmdConfig := createConfigSetCmdConfig(executablePath, key, value) + _, err := gofrogcmd.RunCmdOutput(configGetCmdConfig) + if err != nil { + return errorutils.CheckError(err) + } + return nil +} + +func createConfigSetCmdConfig(executablePath, key, value string) *NpmConfig { + return &NpmConfig{ + Npm: executablePath, + Command: []string{"config", "set", key, value}, + StrWriter: nil, + ErrWriter: nil, + } +} diff --git a/artifactory/utils/repositoryutils.go b/artifactory/utils/repositoryutils.go index 40acbb86d..40e11b30f 100644 --- a/artifactory/utils/repositoryutils.go +++ b/artifactory/utils/repositoryutils.go @@ -2,6 +2,8 @@ package utils import ( "github.com/jfrog/gofrog/datastructures" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" "golang.org/x/exp/slices" "path" "strings" @@ -77,6 +79,31 @@ func IsRemoteRepo(repoName string, serviceManager artifactory.ArtifactoryService return repoDetails.GetRepoType() == "remote", nil } +// SelectRepositoryInteractively prompts the user to select a repository from a list of repositories that match the given filter parameters. +func SelectRepositoryInteractively(serverDetails *config.ServerDetails, repoFilterParams services.RepositoriesFilterParams) (string, error) { + sm, err := CreateServiceManager(serverDetails, 3, 0, false) + if err != nil { + return "", err + } + + filteredRepos, err := GetFilteredRepositoriesWithFilterParams(sm, nil, nil, + repoFilterParams) + if err != nil { + return "", err + } + + if len(filteredRepos) == 0 { + return "", errorutils.CheckErrorf("no repositories were found that match the following criteria: %v", repoFilterParams) + } + + if len(filteredRepos) == 1 { + // Automatically select the repository if only one exists. + return filteredRepos[0], nil + } + // Prompt the user to select a repository. + return ioutils.AskFromListWithMismatchConfirmation("Please select a repository to login to:", "Repository not found.", ioutils.ConvertToSuggests(filteredRepos)), nil +} + // GetFilteredRepositoriesWithFilterParams returns the names of local, remote, virtual, and federated repositories filtered by their names and type. // servicesManager - The Artifactory services manager used to interact with the Artifactory server. // includePatterns - Patterns of repository names (can contain wildcards) to include in the results. A repository's name diff --git a/artifactory/utils/yarn/configget.go b/artifactory/utils/yarn/configget.go index c121e7b62..48d14c3b1 100644 --- a/artifactory/utils/yarn/configget.go +++ b/artifactory/utils/yarn/configget.go @@ -6,6 +6,7 @@ import ( "strings" ) +// This method runs "yarn config set" command and sets the yarn configuration. func ConfigGet(key, executablePath string, jsonOutput bool) (string, error) { var flags []string = nil if jsonOutput { diff --git a/artifactory/utils/yarn/configset.go b/artifactory/utils/yarn/configset.go index ae602ac3a..6f15ed1b7 100644 --- a/artifactory/utils/yarn/configset.go +++ b/artifactory/utils/yarn/configset.go @@ -5,6 +5,7 @@ import ( "github.com/jfrog/jfrog-client-go/utils/errorutils" ) +// This method runs "yarn config set" command and sets the yarn configuration. func ConfigSet(key, value, executablePath string, jsonInput bool) error { var flags []string = nil if jsonInput { diff --git a/common/cliutils/utils.go b/common/cliutils/utils.go index 2eda96d1e..186c34628 100644 --- a/common/cliutils/utils.go +++ b/common/cliutils/utils.go @@ -218,7 +218,6 @@ func CreateServerDetailsWithConfigOffer(createServerDetails func() (*config.Serv return nil, err } log.Debug(fmt.Sprintf("Using <%s> server-id configuration", confDetails.ServerId)) - // Take insecureTls value from options since it is not saved in config. confDetails.InsecureTls = details.InsecureTls confDetails.Url = clientUtils.AddTrailingSlashIfNeeded(confDetails.Url)