diff --git a/artifactory/cli/cli.go b/artifactory/cli/cli.go index fa51ae40..b5892a10 100644 --- a/artifactory/cli/cli.go +++ b/artifactory/cli/cli.go @@ -83,7 +83,7 @@ const ( ) func GetCommands() []components.Command { - return []components.Command{ + commands := []components.Command{ { Name: "upload", Flags: flagkit.GetCommandFlags(flagkit.Upload), @@ -404,6 +404,8 @@ func GetCommands() []components.Command { Category: replicCategory, }, } + + return commands } func getRetries(c *components.Context) (retries int, err error) { diff --git a/artifactory/cli/ide/common.go b/artifactory/cli/ide/common.go new file mode 100644 index 00000000..33e46674 --- /dev/null +++ b/artifactory/cli/ide/common.go @@ -0,0 +1,75 @@ +package ide + +import ( + "fmt" + "net/url" + "strings" + + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" +) + +// ValidateSingleNonEmptyArg checks that there is exactly one argument and it is not empty. +func ValidateSingleNonEmptyArg(c *components.Context, usage string) (string, error) { + if c.GetNumberOfArgs() != 1 { + return "", pluginsCommon.WrongNumberOfArgumentsHandler(c) + } + arg := c.GetArgumentAt(0) + if arg == "" { + return "", fmt.Errorf("argument cannot be empty\n\nUsage: %s", usage) + } + return arg, nil +} + +// HasServerConfigFlags checks if any server configuration flags are provided +func HasServerConfigFlags(c *components.Context) bool { + return c.IsFlagSet("url") || + c.IsFlagSet("user") || + c.IsFlagSet("access-token") || + c.IsFlagSet("server-id") || + // Only consider password if other required fields are also provided + (c.IsFlagSet("password") && (c.IsFlagSet("url") || c.IsFlagSet("server-id"))) +} + +// ExtractRepoKeyFromURL extracts the repository key from both JetBrains and VSCode extension URLs. +// For JetBrains: https://mycompany.jfrog.io/artifactory/api/jetbrainsplugins/jetbrains-plugins +// For VSCode: https://mycompany.jfrog.io/artifactory/api/vscodeextensions/vscode-extensions/_apis/public/gallery +// Returns the repo key (e.g., "jetbrains-plugins" or "vscode-extensions") +func ExtractRepoKeyFromURL(repoURL string) (string, error) { + if repoURL == "" { + return "", fmt.Errorf("URL is empty") + } + + url := strings.TrimSpace(repoURL) + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + url = strings.TrimSuffix(url, "/") + + // Check for JetBrains plugins API + if idx := strings.Index(url, "/api/jetbrainsplugins/"); idx != -1 { + rest := url[idx+len("/api/jetbrainsplugins/"):] + parts := strings.SplitN(rest, "/", 2) + if len(parts) == 0 || parts[0] == "" { + return "", fmt.Errorf("repository key not found in JetBrains URL") + } + return parts[0], nil + } + + // Check for VSCode extensions API + if idx := strings.Index(url, "/api/vscodeextensions/"); idx != -1 { + rest := url[idx+len("/api/vscodeextensions/"):] + parts := strings.SplitN(rest, "/", 2) + if len(parts) == 0 || parts[0] == "" { + return "", fmt.Errorf("repository key not found in VSCode URL") + } + return parts[0], nil + } + + return "", fmt.Errorf("URL does not contain a supported API type (/api/jetbrainsplugins/ or /api/vscodeextensions/)") +} + +// IsValidUrl checks if a string is a valid URL with scheme and host +func IsValidUrl(s string) bool { + u, err := url.Parse(s) + return err == nil && u.Scheme != "" && u.Host != "" +} diff --git a/artifactory/cli/ide/descriptions.go b/artifactory/cli/ide/descriptions.go new file mode 100644 index 00000000..585091de --- /dev/null +++ b/artifactory/cli/ide/descriptions.go @@ -0,0 +1,42 @@ +package ide + +const VscodeConfigDescription = ` +Configure VSCode to use JFrog Artifactory for extensions. + +The service URL should be in the format: +https:///artifactory/api/vscodeextensions//_apis/public/gallery + +Examples: + jf vscode-config https://mycompany.jfrog.io/artifactory/api/vscodeextensions/vscode-extensions/_apis/public/gallery + +This command will: +- Modify the VSCode product.json file to change the extensions gallery URL +- Create an automatic backup before making changes +- Require VSCode to be restarted to apply changes + +Optional: Provide server configuration flags (--url, --user, --password, --access-token, or --server-id) +to enable repository validation. Without these flags, the command will only modify the local VSCode configuration. + +Note: On macOS/Linux, you may need to run with sudo for system-installed VSCode. +` + +const JetbrainsConfigDescription = ` +Configure JetBrains IDEs to use JFrog Artifactory for plugins. + +The repository URL should be in the format: +https:///artifactory/api/jetbrainsplugins/ + +Examples: + jf jetbrains-config https://mycompany.jfrog.io/artifactory/api/jetbrainsplugins/jetbrains-plugins + +This command will: +- Detect all installed JetBrains IDEs +- Modify each IDE's idea.properties file to add the plugins repository URL +- Create automatic backups before making changes +- Require IDEs to be restarted to apply changes + +Optional: Provide server configuration flags (--url, --user, --password, --access-token, or --server-id) +to enable repository validation. Without these flags, the command will only modify the local IDE configuration. + +Supported IDEs: IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, RubyMine, CLion, DataGrip, GoLand, Rider, Android Studio, AppCode, RustRover, Aqua +` diff --git a/artifactory/cli/ide/jetbrains/cli.go b/artifactory/cli/ide/jetbrains/cli.go new file mode 100644 index 00000000..c18c672f --- /dev/null +++ b/artifactory/cli/ide/jetbrains/cli.go @@ -0,0 +1,147 @@ +package jetbrains + +import ( + "fmt" + "strings" + + "github.com/jfrog/gofrog/log" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ide/jetbrains" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" +) + +const ( + repoKeyFlag = "repo-key" + urlSuffixFlag = "url-suffix" + apiType = "jetbrainsplugins" +) + +func GetCommands() []components.Command { + return []components.Command{ + { + Name: "jetbrains-config", + Aliases: []string{"jb"}, + Hidden: true, + Flags: getFlags(), + Arguments: getArguments(), + Action: jetbrainsConfigCmd, + Description: ide.JetbrainsConfigDescription, + }, + } +} + +func getFlags() []components.Flag { + return []components.Flag{ + components.NewStringFlag(repoKeyFlag, "Repository key for the JetBrains plugins repo. [Required if no URL is given]", components.SetMandatoryFalse()), + components.NewStringFlag(urlSuffixFlag, "Suffix for the JetBrains plugins repository URL. Default: (empty)", components.SetMandatoryFalse()), + // Server configuration flags + components.NewStringFlag("url", "JFrog Artifactory URL. (example: https://acme.jfrog.io/artifactory)", components.SetMandatoryFalse()), + components.NewStringFlag("user", "JFrog username.", components.SetMandatoryFalse()), + components.NewStringFlag("password", "JFrog password.", components.SetMandatoryFalse()), + components.NewStringFlag("access-token", "JFrog access token.", components.SetMandatoryFalse()), + components.NewStringFlag("server-id", "Server ID configured using the 'jf config' command.", components.SetMandatoryFalse()), + } +} + +func getArguments() []components.Argument { + return []components.Argument{ + { + Name: "repository-url", + Description: "The Artifactory JetBrains plugins repository URL (optional when using --repo-key)", + Optional: true, + }, + } +} + +// Main command action: orchestrates argument parsing, server config, and command execution +func jetbrainsConfigCmd(c *components.Context) error { + repoKey, repositoryURL, err := getJetbrainsRepoKeyAndURL(c) + if err != nil { + return err + } + + rtDetails, err := getJetbrainsServerDetails(c) + if err != nil { + return err + } + + jetbrainsCmd := jetbrains.NewJetbrainsCommand(repositoryURL, repoKey) + + // Determine if this is a direct URL (argument provided) vs constructed URL (server-id + repo-key) + isDirectURL := c.GetNumberOfArgs() > 0 && ide.IsValidUrl(c.GetArgumentAt(0)) + jetbrainsCmd.SetDirectURL(isDirectURL) + + if rtDetails != nil { + jetbrainsCmd.SetServerDetails(rtDetails) + } + + return jetbrainsCmd.Run() +} + +// getJetbrainsRepoKeyAndURL determines the repo key and repository URL from args/flags +func getJetbrainsRepoKeyAndURL(c *components.Context) (repoKey, repositoryURL string, err error) { + if c.GetNumberOfArgs() > 0 && ide.IsValidUrl(c.GetArgumentAt(0)) { + repositoryURL = c.GetArgumentAt(0) + repoKey, err = ide.ExtractRepoKeyFromURL(repositoryURL) + if err != nil { + return + } + return + } + + repoKey = c.GetStringFlagValue(repoKeyFlag) + if repoKey == "" { + err = fmt.Errorf("You must provide either a repository URL as the first argument or --repo-key flag.") + return + } + // Get Artifactory URL from server details (flags or default) + var artDetails *config.ServerDetails + if ide.HasServerConfigFlags(c) { + artDetails, err = pluginsCommon.CreateArtifactoryDetailsByFlags(c) + if err != nil { + err = fmt.Errorf("Failed to get Artifactory server details: %w", err) + return + } + } else { + artDetails, err = config.GetDefaultServerConf() + if err != nil { + err = fmt.Errorf("Failed to get default Artifactory server details: %w", err) + return + } + } + // Use ArtifactoryUrl if available (when using flags), otherwise use Url (when using config) + baseUrl := artDetails.ArtifactoryUrl + if baseUrl == "" { + baseUrl = artDetails.Url + } + baseUrl = strings.TrimRight(baseUrl, "/") + + urlSuffix := c.GetStringFlagValue(urlSuffixFlag) + if urlSuffix != "" { + urlSuffix = "/" + strings.TrimLeft(urlSuffix, "/") + } + repositoryURL = baseUrl + "/api/jetbrainsplugins/" + repoKey + urlSuffix + return +} + +// getJetbrainsServerDetails returns server details for validation, or nil if not available +func getJetbrainsServerDetails(c *components.Context) (*config.ServerDetails, error) { + if ide.HasServerConfigFlags(c) { + // Use explicit server configuration flags + rtDetails, err := pluginsCommon.CreateArtifactoryDetailsByFlags(c) + if err != nil { + return nil, fmt.Errorf("failed to create server configuration: %w", err) + } + return rtDetails, nil + } + // Use default server configuration for validation when no explicit flags provided + rtDetails, err := config.GetDefaultServerConf() + if err != nil { + // If no default server, that's okay - we'll just skip validation + log.Debug("No default server configuration found, skipping repository validation") + return nil, nil //nolint:nilerr // Intentionally ignoring error to skip validation when no default server + } + return rtDetails, nil +} diff --git a/artifactory/cli/ide/jetbrains/cli_test.go b/artifactory/cli/ide/jetbrains/cli_test.go new file mode 100644 index 00000000..5cbc69b9 --- /dev/null +++ b/artifactory/cli/ide/jetbrains/cli_test.go @@ -0,0 +1,72 @@ +package jetbrains + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/stretchr/testify/assert" +) + +// ... existing code ... + +func TestHasServerConfigFlags(t *testing.T) { + tests := []struct { + name string + flags map[string]string + expected bool + }{ + { + name: "No flags", + flags: map[string]string{}, + expected: false, + }, + { + name: "Only password flag", + flags: map[string]string{"password": "mypass"}, + expected: false, + }, + { + name: "Password and URL flags", + flags: map[string]string{"password": "mypass", "url": "https://example.com"}, + expected: true, + }, + { + name: "Password and server-id flags", + flags: map[string]string{"password": "mypass", "server-id": "my-server"}, + expected: true, + }, + { + name: "URL flag only", + flags: map[string]string{"url": "https://example.com"}, + expected: true, + }, + { + name: "User flag only", + flags: map[string]string{"user": "myuser"}, + expected: true, + }, + { + name: "Access token flag only", + flags: map[string]string{"access-token": "mytoken"}, + expected: true, + }, + { + name: "Server ID flag only", + flags: map[string]string{"server-id": "my-server"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + for flag, value := range tt.flags { + ctx.AddStringFlag(flag, value) + } + + result := ide.HasServerConfigFlags(ctx) + assert.Equal(t, tt.expected, result, "Test case: %s", tt.name) + }) + } +} diff --git a/artifactory/cli/ide/vscode/cli.go b/artifactory/cli/ide/vscode/cli.go new file mode 100644 index 00000000..1f8980ed --- /dev/null +++ b/artifactory/cli/ide/vscode/cli.go @@ -0,0 +1,152 @@ +package vscode + +import ( + "fmt" + "strings" + + "github.com/jfrog/gofrog/log" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ide/vscode" + pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" +) + +const ( + productJsonPath = "product-json-path" + repoKeyFlag = "repo-key" + urlSuffixFlag = "url-suffix" + apiType = "vscodeextensions" +) + +func GetCommands() []components.Command { + return []components.Command{ + { + Name: "vscode-config", + Aliases: []string{"vscode", "code"}, + Hidden: true, + Flags: getFlags(), + Arguments: getArguments(), + Action: vscodeConfigCmd, + Description: ide.VscodeConfigDescription, + }, + } +} + +func getFlags() []components.Flag { + return []components.Flag{ + components.NewStringFlag(productJsonPath, "Path to VSCode product.json file. If not provided, auto-detects VSCode installation.", components.SetMandatoryFalse()), + components.NewStringFlag(repoKeyFlag, "Repository key for the VSCode extensions repo. [Required if no URL is given]", components.SetMandatoryFalse()), + components.NewStringFlag(urlSuffixFlag, "Suffix for the VSCode extensions service URL. Default: _apis/public/gallery", components.SetMandatoryFalse()), + // Server configuration flags + components.NewStringFlag("url", "JFrog Artifactory URL. (example: https://acme.jfrog.io/artifactory)", components.SetMandatoryFalse()), + components.NewStringFlag("user", "JFrog username.", components.SetMandatoryFalse()), + components.NewStringFlag("password", "JFrog password.", components.SetMandatoryFalse()), + components.NewStringFlag("access-token", "JFrog access token.", components.SetMandatoryFalse()), + components.NewStringFlag("server-id", "Server ID configured using the 'jf config' command.", components.SetMandatoryFalse()), + } +} + +func getArguments() []components.Argument { + return []components.Argument{ + { + Name: "service-url", + Description: "The Artifactory VSCode extensions service URL (optional when using --repo-key)", + Optional: true, + }, + } +} + +// Main command action: orchestrates argument parsing, server config, and command execution +func vscodeConfigCmd(c *components.Context) error { + repoKey, serviceURL, err := getVscodeRepoKeyAndURL(c) + if err != nil { + return err + } + + productPath := c.GetStringFlagValue(productJsonPath) + + rtDetails, err := getVscodeServerDetails(c) + if err != nil { + return err + } + + vscodeCmd := vscode.NewVscodeCommand(repoKey, productPath, serviceURL) + + // Determine if this is a direct URL (argument provided) vs constructed URL (server-id + repo-key) + isDirectURL := c.GetNumberOfArgs() > 0 && ide.IsValidUrl(c.GetArgumentAt(0)) + + vscodeCmd.SetDirectURL(isDirectURL) + + if rtDetails != nil { + vscodeCmd.SetServerDetails(rtDetails) + } + + return vscodeCmd.Run() +} + +// getVscodeRepoKeyAndURL determines the repo key and service URL from args/flags +func getVscodeRepoKeyAndURL(c *components.Context) (repoKey, serviceURL string, err error) { + if c.GetNumberOfArgs() > 0 && ide.IsValidUrl(c.GetArgumentAt(0)) { + serviceURL = c.GetArgumentAt(0) + repoKey, err = ide.ExtractRepoKeyFromURL(serviceURL) + if err != nil { + return + } + return + } + + repoKey = c.GetStringFlagValue(repoKeyFlag) + if repoKey == "" { + err = fmt.Errorf("You must provide either a service URL as the first argument or --repo-key flag.") + return + } + // Get Artifactory URL from server details (flags or default) + var artDetails *config.ServerDetails + if ide.HasServerConfigFlags(c) { + artDetails, err = pluginsCommon.CreateArtifactoryDetailsByFlags(c) + if err != nil { + err = fmt.Errorf("Failed to get Artifactory server details: %w", err) + return + } + } else { + artDetails, err = config.GetDefaultServerConf() + if err != nil { + err = fmt.Errorf("Failed to get default Artifactory server details: %w", err) + return + } + } + // Use ArtifactoryUrl if available (when using flags), otherwise use Url (when using config) + baseUrl := artDetails.ArtifactoryUrl + if baseUrl == "" { + baseUrl = artDetails.Url + } + baseUrl = strings.TrimRight(baseUrl, "/") + + urlSuffix := c.GetStringFlagValue(urlSuffixFlag) + if urlSuffix == "" { + urlSuffix = "_apis/public/gallery" + } + serviceURL = baseUrl + "/api/vscodeextensions/" + repoKey + "/" + strings.TrimLeft(urlSuffix, "/") + return +} + +// getVscodeServerDetails returns server details for validation, or nil if not available +func getVscodeServerDetails(c *components.Context) (*config.ServerDetails, error) { + if ide.HasServerConfigFlags(c) { + // Use explicit server configuration flags + rtDetails, err := pluginsCommon.CreateArtifactoryDetailsByFlags(c) + if err != nil { + return nil, fmt.Errorf("failed to create server configuration: %w", err) + } + return rtDetails, nil + } + // Use default server configuration for validation when no explicit flags provided + rtDetails, err := config.GetDefaultServerConf() + if err != nil { + // If no default server, that's okay - we'll just skip validation + log.Debug("No default server configuration found, skipping repository validation") + return nil, nil //nolint:nilerr // Intentionally ignoring error to skip validation when no default server + } + return rtDetails, nil +} diff --git a/artifactory/cli/ide/vscode/cli_test.go b/artifactory/cli/ide/vscode/cli_test.go new file mode 100644 index 00000000..3540c7d9 --- /dev/null +++ b/artifactory/cli/ide/vscode/cli_test.go @@ -0,0 +1,72 @@ +package vscode + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/stretchr/testify/assert" +) + +// ... existing code ... + +func TestHasServerConfigFlags(t *testing.T) { + tests := []struct { + name string + flags map[string]string + expected bool + }{ + { + name: "No flags", + flags: map[string]string{}, + expected: false, + }, + { + name: "Only password flag", + flags: map[string]string{"password": "mypass"}, + expected: false, + }, + { + name: "Password and URL flags", + flags: map[string]string{"password": "mypass", "url": "https://example.com"}, + expected: true, + }, + { + name: "Password and server-id flags", + flags: map[string]string{"password": "mypass", "server-id": "my-server"}, + expected: true, + }, + { + name: "URL flag only", + flags: map[string]string{"url": "https://example.com"}, + expected: true, + }, + { + name: "User flag only", + flags: map[string]string{"user": "myuser"}, + expected: true, + }, + { + name: "Access token flag only", + flags: map[string]string{"access-token": "mytoken"}, + expected: true, + }, + { + name: "Server ID flag only", + flags: map[string]string{"server-id": "my-server"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &components.Context{} + for flag, value := range tt.flags { + ctx.AddStringFlag(flag, value) + } + + result := ide.HasServerConfigFlags(ctx) + assert.Equal(t, tt.expected, result, "Test case: %s", tt.name) + }) + } +} diff --git a/artifactory/commands/ide/ide_instructions.go b/artifactory/commands/ide/ide_instructions.go new file mode 100644 index 00000000..81d95e9a --- /dev/null +++ b/artifactory/commands/ide/ide_instructions.go @@ -0,0 +1,79 @@ +package ide + +const JetbrainsManualInstructionsTemplate = ` +Manual JetBrains IDE Setup Instructions: +======================================= + +1. Close all JetBrains IDEs + +2. Locate your IDE configuration directory: + %s + + Examples: + • IntelliJ IDEA: IntelliJIdea2023.3/idea.properties + • PyCharm: PyCharm2023.3/idea.properties + • WebStorm: WebStorm2023.3/idea.properties + +3. Open or create the idea.properties file in a text editor + +4. Add or modify the following line: + idea.plugins.host=%s + +5. Save the file and restart your IDE + +Repository URL: %s + +Supported IDEs: IntelliJ IDEA, PyCharm, WebStorm, PhpStorm, RubyMine, CLion, DataGrip, GoLand, Rider, Android Studio, AppCode, RustRover, Aqua +` + +const VscodeManualInstructionsTemplate = ` +Manual VSCode Setup Instructions: +================================= + +1. Close VSCode completely + +2. Locate your VSCode installation directory: + • macOS: /Applications/Visual Studio Code.app/Contents/Resources/app/ + • Windows: %%LOCALAPPDATA%%\Programs\Microsoft VS Code\resources\app\ + • Linux: /usr/share/code/resources/app/ + +3. Open the product.json file in a text editor with appropriate permissions: + • macOS: sudo nano "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json" + • Windows: Run editor as Administrator + • Linux: sudo nano /usr/share/code/resources/app/product.json + +4. Find the "extensionsGallery" section and modify the "serviceUrl": + { + "extensionsGallery": { + "serviceUrl": "%s", + ... + } + } + +5. Save the file and restart VSCode + +Service URL: %s +` + +const VscodeMacOSPermissionError = `insufficient permissions to modify VSCode configuration. + +VSCode is installed in /Applications/ which requires elevated privileges to modify. + +To fix this, run the command with sudo: + + sudo jf vscode set service-url '%s' + +This is the same approach that works with manual editing: + sudo nano "%s" + +Note: This does NOT require disabling System Integrity Protection (SIP). +The file is owned by admin and %s needs elevated privileges to write to it. + +Alternative: Install VSCode in a user-writable location like ~/Applications/` + +const VscodeGenericPermissionError = `insufficient permissions to modify VSCode configuration. + +To fix this, try running the command with elevated privileges: + sudo jf vscode set service-url '%s' + +Or use the manual setup instructions provided in the error output.` diff --git a/artifactory/commands/ide/jetbrains/jetbrains.go b/artifactory/commands/ide/jetbrains/jetbrains.go new file mode 100644 index 00000000..77242138 --- /dev/null +++ b/artifactory/commands/ide/jetbrains/jetbrains.go @@ -0,0 +1,386 @@ +package jetbrains + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ide" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "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" +) + +// JetbrainsCommand represents the JetBrains configuration command +type JetbrainsCommand struct { + repositoryURL string + detectedIDEs []IDEInstallation + backupPaths map[string]string + serverDetails *config.ServerDetails + repoKey string + isDirectURL bool // true if URL was provided directly, false if constructed from server-id + repo-key +} + +// IDEInstallation represents a detected JetBrains IDE installation +type IDEInstallation struct { + Name string + Version string + PropertiesPath string + ConfigDir string +} + +// JetBrains IDE product codes and names +var jetbrainsIDEs = map[string]string{ + "IntelliJIdea": "IntelliJ IDEA", + "PyCharm": "PyCharm", + "WebStorm": "WebStorm", + "PhpStorm": "PhpStorm", + "RubyMine": "RubyMine", + "CLion": "CLion", + "DataGrip": "DataGrip", + "GoLand": "GoLand", + "Rider": "Rider", + "AndroidStudio": "Android Studio", + "AppCode": "AppCode", + "RustRover": "RustRover", + "Aqua": "Aqua", +} + +// NewJetbrainsCommand creates a new JetBrains configuration command +func NewJetbrainsCommand(repositoryURL, repoKey string) *JetbrainsCommand { + return &JetbrainsCommand{ + repositoryURL: repositoryURL, + repoKey: repoKey, + backupPaths: make(map[string]string), + isDirectURL: false, // default to false, will be set explicitly + } +} + +func (jc *JetbrainsCommand) SetServerDetails(serverDetails *config.ServerDetails) *JetbrainsCommand { + jc.serverDetails = serverDetails + return jc +} + +func (jc *JetbrainsCommand) ServerDetails() (*config.ServerDetails, error) { + return jc.serverDetails, nil +} + +func (jc *JetbrainsCommand) CommandName() string { + return "rt_jetbrains_config" +} + +// SetDirectURL marks this command as using a direct URL (skip validation) +func (jc *JetbrainsCommand) SetDirectURL(isDirect bool) *JetbrainsCommand { + jc.isDirectURL = isDirect + return jc +} + +// Run executes the JetBrains configuration command +func (jc *JetbrainsCommand) Run() error { + log.Info("Configuring JetBrains IDEs plugin repository...") + + // Only validate repository if we have server details and repo key AND it's not a direct URL + // Skip validation when using direct repository URL since no server-id is involved + if jc.serverDetails != nil && jc.repoKey != "" && !jc.isDirectURL { + if err := jc.validateRepository(); err != nil { + return errorutils.CheckError(fmt.Errorf("repository validation failed: %w", err)) + } + } else if jc.isDirectURL { + log.Debug("Direct repository URL provided, skipping repository validation") + } + + if err := jc.detectJetBrainsIDEs(); err != nil { + return errorutils.CheckError(fmt.Errorf("failed to detect JetBrains IDEs: %w\n\nManual setup instructions:\n%s", err, jc.getManualSetupInstructions(jc.repositoryURL))) + } + + if len(jc.detectedIDEs) == 0 { + return errorutils.CheckError(fmt.Errorf("no JetBrains IDEs found\n\nManual setup instructions:\n%s", jc.getManualSetupInstructions(jc.repositoryURL))) + } + + modifiedCount := 0 + for _, ide := range jc.detectedIDEs { + log.Info("Configuring " + ide.Name + " " + ide.Version + "...") + + if err := jc.createBackup(ide); err != nil { + log.Warn("Failed to create backup for "+ide.Name+":", err) + continue + } + + if err := jc.modifyPropertiesFile(ide, jc.repositoryURL); err != nil { + log.Error("Failed to configure "+ide.Name+":", err) + if restoreErr := jc.restoreBackup(ide); restoreErr != nil { + log.Error("Failed to restore backup for "+ide.Name+":", restoreErr) + } + continue + } + + modifiedCount++ + log.Info(ide.Name + " " + ide.Version + " configured successfully") + } + + if modifiedCount == 0 { + return errorutils.CheckError(fmt.Errorf("failed to configure any JetBrains IDEs\n\nManual setup instructions:\n%s", jc.getManualSetupInstructions(jc.repositoryURL))) + } + + log.Info("Successfully configured", modifiedCount, "out of", len(jc.detectedIDEs), "JetBrains IDE(s). Repository URL:", jc.repositoryURL, "- Please restart your JetBrains IDEs to apply changes") + + return nil +} + +// validateRepository uses the established pattern for repository validation +func (jc *JetbrainsCommand) validateRepository() error { + log.Debug("Validating repository...") + + if jc.serverDetails == nil { + return fmt.Errorf("server details not configured") + } + + artDetails, err := jc.serverDetails.CreateArtAuthConfig() + if err != nil { + return fmt.Errorf("failed to create auth config: %w", err) + } + + if err := utils.ValidateRepoExists(jc.repoKey, artDetails); err != nil { + return fmt.Errorf("repository validation failed: %w", err) + } + + log.Info("Repository validation successful") + return nil +} + +// detectJetBrainsIDEs attempts to auto-detect JetBrains IDE installations +func (jc *JetbrainsCommand) detectJetBrainsIDEs() error { + var configBasePath string + + switch runtime.GOOS { + case "darwin": + // Check for test override first, then use standard HOME location + testHome := os.Getenv("TEST_HOME") + if testHome != "" { + configBasePath = filepath.Join(testHome, "Library", "Application Support", "JetBrains") + } else { + configBasePath = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "JetBrains") + } + case "windows": + // Check for test override first, then use standard APPDATA location + testAppData := os.Getenv("TEST_APPDATA") + if testAppData != "" { + configBasePath = filepath.Join(testAppData, "JetBrains") + } else { + configBasePath = filepath.Join(os.Getenv("APPDATA"), "JetBrains") + } + case "linux": + // Respect XDG_CONFIG_HOME environment variable + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome != "" { + configBasePath = filepath.Join(xdgConfigHome, "JetBrains") + } else { + configBasePath = filepath.Join(os.Getenv("HOME"), ".config", "JetBrains") + } + // Also check legacy location if primary path doesn't exist + if _, err := os.Stat(configBasePath); os.IsNotExist(err) { + legacyPath := filepath.Join(os.Getenv("HOME"), ".JetBrains") + if _, err := os.Stat(legacyPath); err == nil { + configBasePath = legacyPath + } + } + default: + return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + if _, err := os.Stat(configBasePath); os.IsNotExist(err) { + return fmt.Errorf("JetBrains configuration directory not found at: %s", configBasePath) + } + + // Scan for IDE configurations + entries, err := os.ReadDir(configBasePath) + if err != nil { + return fmt.Errorf("failed to read JetBrains configuration directory: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + // Parse IDE name and version from directory name + dirName := entry.Name() + ide := jc.parseIDEFromDirName(dirName) + if ide == nil { + continue + } + + // Set the full config directory path + ide.ConfigDir = filepath.Join(configBasePath, dirName) + + // Set idea.properties file path + ide.PropertiesPath = filepath.Join(ide.ConfigDir, "idea.properties") + + jc.detectedIDEs = append(jc.detectedIDEs, *ide) + } + + // Sort IDEs by name for consistent output + sort.Slice(jc.detectedIDEs, func(i, j int) bool { + return jc.detectedIDEs[i].Name < jc.detectedIDEs[j].Name + }) + + return nil +} + +// parseIDEFromDirName extracts IDE name and version from configuration directory name +func (jc *JetbrainsCommand) parseIDEFromDirName(dirName string) *IDEInstallation { + for productCode, displayName := range jetbrainsIDEs { + if strings.HasPrefix(dirName, productCode) { + // Extract version from directory name (e.g., "IntelliJIdea2023.3" -> "2023.3") + version := strings.TrimPrefix(dirName, productCode) + if version == "" { + version = "Unknown" + } + + return &IDEInstallation{ + Name: displayName, + Version: version, + } + } + } + return nil +} + +// createBackup creates a backup of the original idea.properties file +func (jc *JetbrainsCommand) createBackup(ide IDEInstallation) error { + backupPath := ide.PropertiesPath + ".backup." + time.Now().Format("20060102-150405") + + // If properties file doesn't exist, create an empty backup + if _, err := os.Stat(ide.PropertiesPath); os.IsNotExist(err) { + // Create empty file for backup record + if err := os.WriteFile(backupPath, []byte("# Empty properties file backup\n"), 0644); err != nil { + return fmt.Errorf("failed to create backup marker: %w", err) + } + jc.backupPaths[ide.PropertiesPath] = backupPath + return nil + } + + // Read existing properties file + data, err := os.ReadFile(ide.PropertiesPath) + if err != nil { + return fmt.Errorf("failed to read properties file: %w", err) + } + + // Write backup + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + jc.backupPaths[ide.PropertiesPath] = backupPath + log.Info("Backup created at:", backupPath) + return nil +} + +// restoreBackup restores the backup in case of failure +func (jc *JetbrainsCommand) restoreBackup(ide IDEInstallation) error { + backupPath, exists := jc.backupPaths[ide.PropertiesPath] + if !exists { + return fmt.Errorf("no backup path available for %s", ide.PropertiesPath) + } + + data, err := os.ReadFile(backupPath) + if err != nil { + return fmt.Errorf("failed to read backup: %w", err) + } + + // Check if this was an empty file backup + if strings.Contains(string(data), "# Empty properties file backup") { + // Remove the properties file if it was created + if err := os.Remove(ide.PropertiesPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove created properties file: %w", err) + } + return nil + } + + if err := os.WriteFile(ide.PropertiesPath, data, 0644); err != nil { + return fmt.Errorf("failed to restore backup: %w", err) + } + + log.Info("Backup restored for", ide.Name) + return nil +} + +// modifyPropertiesFile modifies or creates the idea.properties file +func (jc *JetbrainsCommand) modifyPropertiesFile(ide IDEInstallation, repositoryURL string) error { + var lines []string + var pluginsHostSet bool + + // Read existing properties if file exists + if _, err := os.Stat(ide.PropertiesPath); err == nil { + data, err := os.ReadFile(ide.PropertiesPath) + if err != nil { + return fmt.Errorf("failed to read properties file: %w", err) + } + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := scanner.Text() + trimmedLine := strings.TrimSpace(line) + + // Check if this line sets idea.plugins.host + if strings.HasPrefix(trimmedLine, "idea.plugins.host=") { + // Replace with our repository URL + lines = append(lines, fmt.Sprintf("idea.plugins.host=%s", repositoryURL)) + pluginsHostSet = true + log.Info("Updated existing idea.plugins.host property") + } else { + lines = append(lines, line) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to scan properties file: %w", err) + } + } + + // Add idea.plugins.host if not found + if !pluginsHostSet { + if len(lines) > 0 { + lines = append(lines, "") // Add empty line for readability + } + lines = append(lines, "# JFrog Artifactory plugins repository") + lines = append(lines, fmt.Sprintf("idea.plugins.host=%s", repositoryURL)) + log.Info("Added idea.plugins.host property") + } + + // Ensure config directory exists + if err := os.MkdirAll(filepath.Dir(ide.PropertiesPath), 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Write modified properties file + content := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(ide.PropertiesPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write properties file: %w", err) + } + + return nil +} + +// getManualSetupInstructions returns manual setup instructions +func (jc *JetbrainsCommand) getManualSetupInstructions(repositoryURL string) string { + var configPath string + switch runtime.GOOS { + case "darwin": + configPath = "~/Library/Application Support/JetBrains/[IDE][VERSION]/idea.properties" + case "windows": + configPath = "%APPDATA%\\JetBrains\\[IDE][VERSION]\\idea.properties" + case "linux": + configPath = "~/.config/JetBrains/[IDE][VERSION]/idea.properties" + default: + configPath = "[JetBrains config directory]/[IDE][VERSION]/idea.properties" + } + + return fmt.Sprintf(ide.JetbrainsManualInstructionsTemplate, configPath, repositoryURL, repositoryURL) +} diff --git a/artifactory/commands/ide/jetbrains/jetbrains_test.go b/artifactory/commands/ide/jetbrains/jetbrains_test.go new file mode 100644 index 00000000..a9fe0b04 --- /dev/null +++ b/artifactory/commands/ide/jetbrains/jetbrains_test.go @@ -0,0 +1,511 @@ +package jetbrains + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewJetbrainsCommand(t *testing.T) { + repositoryURL := "https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo" + repoKey := "repo" + + cmd := NewJetbrainsCommand(repositoryURL, repoKey) + + assert.Equal(t, repositoryURL, cmd.repositoryURL) + assert.Equal(t, repoKey, cmd.repoKey) + assert.NotNil(t, cmd.backupPaths) + assert.Equal(t, 0, len(cmd.backupPaths)) + assert.Equal(t, 0, len(cmd.detectedIDEs)) +} + +func TestJetbrainsCommand_CommandName(t *testing.T) { + cmd := NewJetbrainsCommand("", "") + assert.Equal(t, "rt_jetbrains_config", cmd.CommandName()) +} + +func TestJetbrainsCommand_SetServerDetails(t *testing.T) { + cmd := NewJetbrainsCommand("", "") + serverDetails := &config.ServerDetails{ + Url: "https://company.jfrog.io", + ArtifactoryUrl: "https://company.jfrog.io/artifactory", + AccessToken: "test-token", + } + + result := cmd.SetServerDetails(serverDetails) + + assert.Equal(t, serverDetails, cmd.serverDetails) + assert.Equal(t, cmd, result) // Should return self for chaining +} + +func TestJetbrainsCommand_ServerDetails(t *testing.T) { + cmd := NewJetbrainsCommand("", "") + serverDetails := &config.ServerDetails{ + Url: "https://company.jfrog.io", + ArtifactoryUrl: "https://company.jfrog.io/artifactory", + AccessToken: "test-token", + } + + cmd.SetServerDetails(serverDetails) + + details, err := cmd.ServerDetails() + assert.NoError(t, err) + assert.Equal(t, serverDetails, details) +} + +func TestJetbrainsCommand_ParseIDEFromDirName(t *testing.T) { + cmd := NewJetbrainsCommand("", "") + + testCases := []struct { + dirName string + expectedName string + expectedVer string + shouldParse bool + }{ + {"IntelliJIdea2023.3", "IntelliJ IDEA", "2023.3", true}, + {"PyCharm2023.3", "PyCharm", "2023.3", true}, + {"WebStorm2023.2.1", "WebStorm", "2023.2.1", true}, + {"PhpStorm2024.1", "PhpStorm", "2024.1", true}, + {"RubyMine2023.3", "RubyMine", "2023.3", true}, + {"CLion2023.3", "CLion", "2023.3", true}, + {"DataGrip2023.3", "DataGrip", "2023.3", true}, + {"GoLand2023.3", "GoLand", "2023.3", true}, + {"Rider2023.3", "Rider", "2023.3", true}, + {"AndroidStudio2023.1", "Android Studio", "2023.1", true}, + {"AppCode2023.3", "AppCode", "2023.3", true}, + {"RustRover2023.3", "RustRover", "2023.3", true}, + {"Aqua2023.3", "Aqua", "2023.3", true}, + {"UnknownIDE2023.3", "", "", false}, + {"invalidname", "", "", false}, + {"", "", "", false}, + } + + for _, tc := range testCases { + t.Run(tc.dirName, func(t *testing.T) { + ide := cmd.parseIDEFromDirName(tc.dirName) + if tc.shouldParse { + require.NotNil(t, ide) + assert.Equal(t, tc.expectedName, ide.Name) + assert.Equal(t, tc.expectedVer, ide.Version) + } else { + assert.Nil(t, ide) + } + }) + } +} + +func TestJetbrainsCommand_DetectJetBrainsIDEs(t *testing.T) { + // This test verifies that the detection method runs without error + // but skips actual file checks since they depend on the environment + cmd := NewJetbrainsCommand("", "") + + // The method should not panic and should handle missing installations gracefully + err := cmd.detectJetBrainsIDEs() + // We expect an error since JetBrains IDEs likely aren't installed in the test environment + // But the method should handle it gracefully + if err != nil { + assert.Contains(t, err.Error(), "JetBrains") + } +} + +func TestJetbrainsCommand_DetectJetBrainsIDEs_WithXDGConfigHome(t *testing.T) { + if runtime.GOOS != "linux" { + // For testing purposes, we can't change runtime.GOOS, so skip on non-Linux + t.Skip("XDG_CONFIG_HOME test is only fully testable on Linux") + } + + // Create temporary directory for XDG_CONFIG_HOME + tempDir := t.TempDir() + xdgConfigHome := filepath.Join(tempDir, "config") + + // Create mock JetBrains configuration directory structure + jetbrainsDir := filepath.Join(xdgConfigHome, "JetBrains") + ideaDir := filepath.Join(jetbrainsDir, "IntelliJIdea2023.3") + err := os.MkdirAll(ideaDir, 0755) + require.NoError(t, err) + + // Create mock idea.properties file + propertiesPath := filepath.Join(ideaDir, "idea.properties") + err = os.WriteFile(propertiesPath, []byte("# Test properties\n"), 0644) + require.NoError(t, err) + + // Set XDG_CONFIG_HOME environment variable + originalXDG := os.Getenv("XDG_CONFIG_HOME") + defer func() { + if originalXDG != "" { + if err := os.Setenv("XDG_CONFIG_HOME", originalXDG); err != nil { + t.Logf("Warning: failed to restore XDG_CONFIG_HOME: %v", err) + } + } else { + if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil { + t.Logf("Warning: failed to unset XDG_CONFIG_HOME: %v", err) + } + } + }() + err = os.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + require.NoError(t, err) + + // Test detection + cmd := NewJetbrainsCommand("", "") + err = cmd.detectJetBrainsIDEs() + assert.NoError(t, err, "Detection should succeed when XDG_CONFIG_HOME is set") + assert.Len(t, cmd.detectedIDEs, 1, "Should detect one IDE") + + if len(cmd.detectedIDEs) > 0 { + ide := cmd.detectedIDEs[0] + assert.Equal(t, "IntelliJ IDEA", ide.Name) + assert.Equal(t, "2023.3", ide.Version) + assert.Equal(t, propertiesPath, ide.PropertiesPath) + assert.Equal(t, ideaDir, ide.ConfigDir) + } +} + +func TestJetbrainsCommand_CreateBackup(t *testing.T) { + // Create temporary idea.properties file + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + originalContent := []byte(`# IDE Configuration +ide.config.path=${user.home}/.config/JetBrains/IntelliJIdea2023.3 +`) + + err := os.WriteFile(propertiesPath, originalContent, 0644) + require.NoError(t, err) + + cmd := NewJetbrainsCommand("", "") + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err = cmd.createBackup(ide) + assert.NoError(t, err) + + // Verify backup was created + backupPath, exists := cmd.backupPaths[propertiesPath] + assert.True(t, exists) + assert.FileExists(t, backupPath) + + // Verify backup content + backupContent, err := os.ReadFile(backupPath) + require.NoError(t, err) + assert.Equal(t, originalContent, backupContent) +} + +func TestJetbrainsCommand_CreateBackup_NonExistentFile(t *testing.T) { + // Test backup creation when properties file doesn't exist + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + + cmd := NewJetbrainsCommand("", "") + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err := cmd.createBackup(ide) + assert.NoError(t, err) + + // Verify backup marker was created + backupPath, exists := cmd.backupPaths[propertiesPath] + assert.True(t, exists) + assert.FileExists(t, backupPath) + + // Verify backup is marked as empty + backupContent, err := os.ReadFile(backupPath) + require.NoError(t, err) + assert.Contains(t, string(backupContent), "Empty properties file backup") +} + +func TestJetbrainsCommand_RestoreBackup(t *testing.T) { + // Create temporary files + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + backupPath := propertiesPath + ".backup" + + // Create original backup content + originalContent := []byte(`# IDE Configuration +ide.config.path=${user.home}/.config/JetBrains/IntelliJIdea2023.3 +`) + err := os.WriteFile(backupPath, originalContent, 0644) + require.NoError(t, err) + + // Create modified properties file + modifiedContent := []byte(`# IDE Configuration +ide.config.path=${user.home}/.config/JetBrains/IntelliJIdea2023.3 +idea.plugins.host=https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo +`) + err = os.WriteFile(propertiesPath, modifiedContent, 0644) + require.NoError(t, err) + + cmd := NewJetbrainsCommand("", "") + cmd.backupPaths[propertiesPath] = backupPath + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err = cmd.restoreBackup(ide) + assert.NoError(t, err) + + // Verify restoration + restoredContent, err := os.ReadFile(propertiesPath) + require.NoError(t, err) + assert.Equal(t, originalContent, restoredContent) +} + +func TestJetbrainsCommand_RestoreBackup_EmptyFile(t *testing.T) { + // Test restoration of an empty file backup + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + backupPath := propertiesPath + ".backup" + + // Create empty file backup marker + emptyBackupContent := []byte("# Empty properties file backup\n") + err := os.WriteFile(backupPath, emptyBackupContent, 0644) + require.NoError(t, err) + + // Create properties file + modifiedContent := []byte(`# IDE Configuration +idea.plugins.host=https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo +`) + err = os.WriteFile(propertiesPath, modifiedContent, 0644) + require.NoError(t, err) + + cmd := NewJetbrainsCommand("", "") + cmd.backupPaths[propertiesPath] = backupPath + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err = cmd.restoreBackup(ide) + assert.NoError(t, err) + + // Verify file was removed + _, err = os.Stat(propertiesPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestJetbrainsCommand_ModifyPropertiesFile_NewFile(t *testing.T) { + // Test creating a new properties file + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + repositoryURL := "https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo" + + cmd := NewJetbrainsCommand("", "") + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err := cmd.modifyPropertiesFile(ide, repositoryURL) + assert.NoError(t, err) + + // Verify file was created + assert.FileExists(t, propertiesPath) + + // Verify content + content, err := os.ReadFile(propertiesPath) + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "idea.plugins.host="+repositoryURL) + assert.Contains(t, contentStr, "JFrog Artifactory plugins repository") +} + +func TestJetbrainsCommand_ModifyPropertiesFile_ExistingFile(t *testing.T) { + // Test modifying an existing properties file + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + repositoryURL := "https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo" + + // Create existing properties file + originalContent := `# IDE Configuration +ide.config.path=${user.home}/.config/JetBrains/IntelliJIdea2023.3 +ide.system.path=${user.home}/.local/share/JetBrains/IntelliJIdea2023.3 +` + err := os.WriteFile(propertiesPath, []byte(originalContent), 0644) + require.NoError(t, err) + + cmd := NewJetbrainsCommand("", "") + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err = cmd.modifyPropertiesFile(ide, repositoryURL) + assert.NoError(t, err) + + // Verify file was modified + content, err := os.ReadFile(propertiesPath) + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, originalContent) + assert.Contains(t, contentStr, "idea.plugins.host="+repositoryURL) + assert.Contains(t, contentStr, "JFrog Artifactory plugins repository") +} + +func TestJetbrainsCommand_ModifyPropertiesFile_UpdateExisting(t *testing.T) { + // Test updating an existing idea.plugins.host entry + tempDir := t.TempDir() + propertiesPath := filepath.Join(tempDir, "idea.properties") + repositoryURL := "https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo" + + // Create existing properties file with plugins host + originalContent := `# IDE Configuration +ide.config.path=${user.home}/.config/JetBrains/IntelliJIdea2023.3 +idea.plugins.host=https://old-repo.com/plugins +ide.system.path=${user.home}/.local/share/JetBrains/IntelliJIdea2023.3 +` + err := os.WriteFile(propertiesPath, []byte(originalContent), 0644) + require.NoError(t, err) + + cmd := NewJetbrainsCommand("", "") + ide := IDEInstallation{ + Name: "IntelliJ IDEA", + Version: "2023.3", + PropertiesPath: propertiesPath, + ConfigDir: tempDir, + } + + err = cmd.modifyPropertiesFile(ide, repositoryURL) + assert.NoError(t, err) + + // Verify file was modified + content, err := os.ReadFile(propertiesPath) + require.NoError(t, err) + contentStr := string(content) + assert.Contains(t, contentStr, "idea.plugins.host="+repositoryURL) + assert.NotContains(t, contentStr, "https://old-repo.com/plugins") +} + +func TestJetbrainsCommand_GetManualSetupInstructions(t *testing.T) { + cmd := NewJetbrainsCommand("", "") + repositoryURL := "https://company.jfrog.io/artifactory/api/jetbrainsplugins/repo" + + instructions := cmd.getManualSetupInstructions(repositoryURL) + + assert.NotEmpty(t, instructions) + assert.Contains(t, instructions, repositoryURL) + assert.Contains(t, instructions, "idea.properties") + assert.Contains(t, instructions, "idea.plugins.host") + + // Should contain platform-specific instructions + switch runtime.GOOS { + case "darwin": + assert.Contains(t, instructions, "Library/Application Support") + case "windows": + assert.Contains(t, instructions, "APPDATA") + case "linux": + assert.Contains(t, instructions, ".config") + } + + // Should contain supported IDEs + assert.Contains(t, instructions, "IntelliJ IDEA") + assert.Contains(t, instructions, "PyCharm") + assert.Contains(t, instructions, "WebStorm") +} + +func TestJetbrainsCommand_ValidateRepository_NoServerDetails(t *testing.T) { + cmd := NewJetbrainsCommand("", "repo") + + // Should return error when no server details are set + err := cmd.validateRepository() + assert.Error(t, err) + assert.Contains(t, err.Error(), "server details not configured") +} + +// Benchmark tests for performance +func BenchmarkJetbrainsCommand_DetectJetBrainsIDEs(b *testing.B) { + cmd := NewJetbrainsCommand("", "") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Reset detected IDEs + cmd.detectedIDEs = nil + _ = cmd.detectJetBrainsIDEs() + } +} + +func BenchmarkJetbrainsCommand_ParseIDEFromDirName(b *testing.B) { + cmd := NewJetbrainsCommand("", "") + testDirs := []string{ + "IntelliJIdea2023.3", + "PyCharm2023.3", + "WebStorm2023.2.1", + "PhpStorm2024.1", + "InvalidDir", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, dir := range testDirs { + _ = cmd.parseIDEFromDirName(dir) + } + } +} + +func TestJetbrainsCommand_DetectJetBrainsIDEs_WithTestAppData(t *testing.T) { + // Test TEST_APPDATA support for Windows testing environments + + // Create temporary directory for TEST_APPDATA + tempDir := t.TempDir() + + // Create mock JetBrains configuration directory structure + jetbrainsDir := filepath.Join(tempDir, "JetBrains") + ideaDir := filepath.Join(jetbrainsDir, "IntelliJIdea2023.3") + err := os.MkdirAll(ideaDir, 0755) + require.NoError(t, err) + + // Create mock idea.properties file + propertiesPath := filepath.Join(ideaDir, "idea.properties") + propertiesContent := "# Test properties\nide.system.path=${user.home}/.local/share/JetBrains/IntelliJIdea2023.3\n" + err = os.WriteFile(propertiesPath, []byte(propertiesContent), 0644) + require.NoError(t, err) + + // Set TEST_APPDATA environment variable + originalTestAppData := os.Getenv("TEST_APPDATA") + defer func() { + if originalTestAppData != "" { + _ = os.Setenv("TEST_APPDATA", originalTestAppData) + } else { + _ = os.Unsetenv("TEST_APPDATA") + } + }() + err = os.Setenv("TEST_APPDATA", tempDir) + require.NoError(t, err) + + // Create JetBrains command and test detection + cmd := &JetbrainsCommand{} + + // For testing, temporarily modify runtime.GOOS to simulate Windows behavior + // Note: We can't actually change runtime.GOOS, so this test documents the intended behavior + if runtime.GOOS == "windows" { + // Run detection - should find our mock IDE + err = cmd.detectJetBrainsIDEs() + // Should succeed when TEST_APPDATA points to our mock directory + require.NoError(t, err) + require.Len(t, cmd.detectedIDEs, 1) + require.Equal(t, "IntelliJ IDEA", cmd.detectedIDEs[0].Name) + require.Equal(t, "2023.3", cmd.detectedIDEs[0].Version) + } else { + // On non-Windows, TEST_APPDATA should not affect detection + t.Logf("TEST_APPDATA test is primarily for Windows environments") + } +} diff --git a/artifactory/commands/ide/vscode/vscode.go b/artifactory/commands/ide/vscode/vscode.go new file mode 100644 index 00000000..866750d6 --- /dev/null +++ b/artifactory/commands/ide/vscode/vscode.go @@ -0,0 +1,330 @@ +package vscode + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/jfrog/jfrog-cli-artifactory/artifactory/commands/ide" + "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// VscodeCommand represents the VSCode configuration command +type VscodeCommand struct { + serviceURL string + productPath string + backupPath string + serverDetails *config.ServerDetails + repoKey string + isDirectURL bool // true if URL was provided directly, false if constructed from server-id + repo-key +} + +// NewVscodeCommand creates a new VSCode configuration command +func NewVscodeCommand(repoKey, productPath, serviceURL string) *VscodeCommand { + return &VscodeCommand{ + repoKey: repoKey, + productPath: productPath, + serviceURL: serviceURL, + isDirectURL: false, // default to false, will be set explicitly + } +} + +func (vc *VscodeCommand) SetServerDetails(serverDetails *config.ServerDetails) *VscodeCommand { + vc.serverDetails = serverDetails + return vc +} + +func (vc *VscodeCommand) ServerDetails() (*config.ServerDetails, error) { + return vc.serverDetails, nil +} + +func (vc *VscodeCommand) CommandName() string { + return "rt_vscode_config" +} + +// SetDirectURL marks this command as using a direct URL (skip validation) +func (vc *VscodeCommand) SetDirectURL(isDirect bool) *VscodeCommand { + vc.isDirectURL = isDirect + return vc +} + +// Run executes the VSCode configuration command +func (vc *VscodeCommand) Run() error { + log.Info("Configuring VSCode extensions repository...") + + // Only validate repository if we have server details and repo key AND it's not a direct URL + // Skip validation when using direct service URL since no server-id is involved + if vc.serverDetails != nil && vc.repoKey != "" && !vc.isDirectURL { + if err := vc.validateRepository(); err != nil { + return errorutils.CheckError(fmt.Errorf("repository validation failed: %w", err)) + } + } else if vc.isDirectURL { + log.Debug("Direct service URL provided, skipping repository validation") + } + + if vc.productPath == "" { + detectedPath, err := vc.detectVSCodeInstallation() + if err != nil { + return errorutils.CheckError(fmt.Errorf("failed to auto-detect VSCode installation: %w\n\nManual setup instructions:\n%s", err, vc.getManualSetupInstructions(vc.serviceURL))) + } + vc.productPath = detectedPath + log.Info("Detected VSCode at:", vc.productPath) + } + + if err := vc.modifyProductJson(vc.serviceURL); err != nil { + if restoreErr := vc.restoreBackup(); restoreErr != nil { + log.Error("Failed to restore backup:", restoreErr) + } + return errorutils.CheckError(fmt.Errorf("failed to modify product.json: %w\n\nManual setup instructions:\n%s", err, vc.getManualSetupInstructions(vc.serviceURL))) + } + + log.Info("VSCode configuration updated successfully. Repository URL:", vc.serviceURL, "- Please restart VSCode to apply changes") + return nil +} + +// validateRepository uses the established pattern for repository validation +func (vc *VscodeCommand) validateRepository() error { + log.Debug("Validating repository...") + + if vc.serverDetails == nil { + return fmt.Errorf("server details not configured - please run 'jf config add' first") + } + + artDetails, err := vc.serverDetails.CreateArtAuthConfig() + if err != nil { + return fmt.Errorf("failed to create auth config: %w", err) + } + + if err := utils.ValidateRepoExists(vc.repoKey, artDetails); err != nil { + return fmt.Errorf("repository validation failed: %w", err) + } + + log.Info("Repository validation successful") + return nil +} + +// checkWritePermissions checks if we have write permissions to the product.json file +func (vc *VscodeCommand) checkWritePermissions() error { + // Check if file exists and we can read it + info, err := os.Stat(vc.productPath) + if err != nil { + return fmt.Errorf("failed to access product.json: %w", err) + } + + if runtime.GOOS != "windows" { + if os.Getuid() == 0 { + return nil + } + } + + file, err := os.OpenFile(vc.productPath, os.O_WRONLY|os.O_APPEND, info.Mode()) + if err != nil { + if os.IsPermission(err) { + return vc.handlePermissionError() + } + return fmt.Errorf("failed to check write permissions: %w", err) + } + if closeErr := file.Close(); closeErr != nil { + return fmt.Errorf("failed to close file: %w", closeErr) + } + return nil +} + +// handlePermissionError provides appropriate guidance based on the operating system +func (vc *VscodeCommand) handlePermissionError() error { + if runtime.GOOS == "darwin" && strings.HasPrefix(vc.productPath, "/Applications/") { + userInfo := "the current user" + if user := os.Getenv("USER"); user != "" { + userInfo = user + } + return fmt.Errorf(ide.VscodeMacOSPermissionError, vc.serviceURL, vc.productPath, userInfo) + } + return fmt.Errorf(ide.VscodeGenericPermissionError, vc.serviceURL) +} + +// detectVSCodeInstallation attempts to auto-detect VSCode installation +func (vc *VscodeCommand) detectVSCodeInstallation() (string, error) { + var possiblePaths []string + + switch runtime.GOOS { + case "darwin": + possiblePaths = []string{ + "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json", + "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/product.json", + // Add user-installed locations + filepath.Join(os.Getenv("HOME"), "Applications", "Visual Studio Code.app", "Contents", "Resources", "app", "product.json"), + } + case "windows": + possiblePaths = []string{ + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Microsoft VS Code", "resources", "app", "product.json"), + filepath.Join(os.Getenv("PROGRAMFILES"), "Microsoft VS Code", "resources", "app", "product.json"), + filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "Microsoft VS Code", "resources", "app", "product.json"), + } + case "linux": + possiblePaths = []string{ + "/usr/share/code/resources/app/product.json", + "/opt/visual-studio-code/resources/app/product.json", + "/snap/code/current/usr/share/code/resources/app/product.json", + filepath.Join(os.Getenv("HOME"), ".vscode-server", "bin", "*", "product.json"), + } + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + // Handle glob patterns for Linux + if strings.Contains(path, "*") { + matches, _ := filepath.Glob(path) + for _, match := range matches { + if _, err := os.Stat(match); err == nil { + return match, nil + } + } + } + } + + return "", fmt.Errorf("VSCode installation not found in standard locations") +} + +// createBackup creates a backup of the original product.json +func (vc *VscodeCommand) createBackup() error { + backupDir, err := coreutils.GetJfrogBackupDir() + if err != nil { + return fmt.Errorf("failed to get JFrog backup directory: %w", err) + } + + ideBackupDir := filepath.Join(backupDir, "ide", "vscode") + err = fileutils.CreateDirIfNotExist(ideBackupDir) + if err != nil { + return fmt.Errorf("failed to create IDE backup directory: %w", err) + } + + timestamp := time.Now().Format("20060102-150405") + backupFileName := "product.json.backup." + timestamp + vc.backupPath = filepath.Join(ideBackupDir, backupFileName) + + data, err := os.ReadFile(vc.productPath) + if err != nil { + return fmt.Errorf("failed to read original product.json: %w", err) + } + + if err := os.WriteFile(vc.backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + log.Info("Backup created at:", vc.backupPath) + return nil +} + +// restoreBackup restores the backup in case of failure +func (vc *VscodeCommand) restoreBackup() error { + if vc.backupPath == "" { + return fmt.Errorf("no backup path available") + } + + data, err := os.ReadFile(vc.backupPath) + if err != nil { + return fmt.Errorf("failed to read backup: %w", err) + } + + if err := os.WriteFile(vc.productPath, data, 0644); err != nil { + return fmt.Errorf("failed to restore backup: %w", err) + } + return nil +} + +// modifyProductJson modifies the VSCode product.json file +func (vc *VscodeCommand) modifyProductJson(repoURL string) error { + // Check write permissions first + if err := vc.checkWritePermissions(); err != nil { + return err + } + + // Create backup first + if err := vc.createBackup(); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + var err error + if runtime.GOOS == "windows" { + err = vc.modifyWithPowerShell(repoURL) + } else { + err = vc.modifyWithSed(repoURL) + } + + if err != nil { + if restoreErr := vc.restoreBackup(); restoreErr != nil { + log.Error("Failed to restore backup:", restoreErr) + } + return err + } + + return nil +} + +// modifyWithSed modifies the product.json file using sed +func (vc *VscodeCommand) modifyWithSed(repoURL string) error { + // Escape special characters for sed + escapedURL := strings.ReplaceAll(repoURL, "/", "\\/") + escapedURL = strings.ReplaceAll(escapedURL, "&", "\\&") + + // sed command to replace serviceUrl in the JSON file (handles both compact and formatted JSON) + sedCommand := fmt.Sprintf(`s/"serviceUrl" *: *"[^"]*"/"serviceUrl": "%s"/g`, escapedURL) + + // Run sed command - different platforms handle -i differently + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + // macOS requires empty string after -i for no backup + cmd = exec.Command("sed", "-i", "", sedCommand, vc.productPath) + } else { + // Linux and other Unix systems + cmd = exec.Command("sed", "-i", sedCommand, vc.productPath) + } + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to modify product.json with sed: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// modifyWithPowerShell modifies the product.json file using PowerShell +func (vc *VscodeCommand) modifyWithPowerShell(repoURL string) error { + // Escape quotes for PowerShell + escapedURL := strings.ReplaceAll(repoURL, `"`, `\"`) + + // PowerShell command to replace serviceUrl in the JSON file (handles both compact and formatted JSON) + // Uses PowerShell's -replace operator which works similar to sed + psCommand := fmt.Sprintf(`(Get-Content "%s") -replace '"serviceUrl" *: *"[^"]*"', '"serviceUrl": "%s"' | Set-Content "%s"`, + vc.productPath, escapedURL, vc.productPath) + + // Run PowerShell command + // Note: This requires the JF CLI to be run as Administrator on Windows + cmd := exec.Command("powershell", "-Command", psCommand) + + if output, err := cmd.CombinedOutput(); err != nil { + if strings.Contains(string(output), "Access") && strings.Contains(string(output), "denied") { + return fmt.Errorf("access denied - please run JF CLI as Administrator on Windows") + } + return fmt.Errorf("failed to modify product.json with PowerShell: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// getManualSetupInstructions returns manual setup instructions +func (vc *VscodeCommand) getManualSetupInstructions(serviceURL string) string { + return fmt.Sprintf(ide.VscodeManualInstructionsTemplate, serviceURL, serviceURL) +} diff --git a/artifactory/commands/ide/vscode/vscode_test.go b/artifactory/commands/ide/vscode/vscode_test.go new file mode 100644 index 00000000..b19ad7f8 --- /dev/null +++ b/artifactory/commands/ide/vscode/vscode_test.go @@ -0,0 +1,226 @@ +package vscode + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewVscodeCommand(t *testing.T) { + serviceURL := "https://company.jfrog.io/artifactory/api/vscodeextensions/repo/_apis/public/gallery" + productPath := "/custom/path/product.json" + repoKey := "repo" + + cmd := NewVscodeCommand(repoKey, productPath, serviceURL) + + assert.Equal(t, serviceURL, cmd.serviceURL) + assert.Equal(t, productPath, cmd.productPath) + assert.Equal(t, repoKey, cmd.repoKey) +} + +func TestVscodeCommand_CommandName(t *testing.T) { + cmd := NewVscodeCommand("", "", "") + assert.Equal(t, "rt_vscode_config", cmd.CommandName()) +} + +func TestVscodeCommand_SetServerDetails(t *testing.T) { + cmd := NewVscodeCommand("", "", "") + serverDetails := &config.ServerDetails{ + Url: "https://company.jfrog.io", + ArtifactoryUrl: "https://company.jfrog.io/artifactory", + AccessToken: "test-token", + } + + result := cmd.SetServerDetails(serverDetails) + + assert.Equal(t, serverDetails, cmd.serverDetails) + assert.Equal(t, cmd, result) // Should return self for chaining +} + +func TestVscodeCommand_ServerDetails(t *testing.T) { + cmd := NewVscodeCommand("", "", "") + serverDetails := &config.ServerDetails{ + Url: "https://company.jfrog.io", + ArtifactoryUrl: "https://company.jfrog.io/artifactory", + AccessToken: "test-token", + } + + cmd.SetServerDetails(serverDetails) + + details, err := cmd.ServerDetails() + assert.NoError(t, err) + assert.Equal(t, serverDetails, details) +} + +func TestVscodeCommand_DetectVSCodeInstallation(t *testing.T) { + // This test verifies that the detection method runs without error + // but skips actual file checks since they depend on the environment + cmd := NewVscodeCommand("", "", "") + + // The method should not panic and should handle missing installations gracefully + _, err := cmd.detectVSCodeInstallation() + // We expect an error since VSCode likely isn't installed in the test environment + // But the method should handle it gracefully + if err != nil { + assert.Contains(t, err.Error(), "VSCode") + } +} + +func TestVscodeCommand_CheckWritePermissions_NonExistentFile(t *testing.T) { + cmd := NewVscodeCommand("", "/non/existent/path/product.json", "") + + err := cmd.checkWritePermissions() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to access product.json") +} + +func TestVscodeCommand_CreateBackup(t *testing.T) { + // Create temporary product.json file + tempDir := t.TempDir() + productPath := filepath.Join(tempDir, "product.json") + originalContent := []byte(`{"extensionsGallery": {"serviceUrl": "https://marketplace.visualstudio.com"}}`) + + err := os.WriteFile(productPath, originalContent, 0644) + require.NoError(t, err) + + cmd := NewVscodeCommand("", productPath, "") + + err = cmd.createBackup() + assert.NoError(t, err) + + // Verify backup was created (it's in JFrog backup directory, not same directory) + assert.NotEmpty(t, cmd.backupPath) + assert.FileExists(t, cmd.backupPath) + + // Verify backup content + backupContent, err := os.ReadFile(cmd.backupPath) + require.NoError(t, err) + assert.Equal(t, originalContent, backupContent) +} + +func TestVscodeCommand_RestoreBackup(t *testing.T) { + // Create temporary files + tempDir := t.TempDir() + productPath := filepath.Join(tempDir, "product.json") + backupPath := productPath + ".backup" + + // Create original backup content + originalContent := []byte(`{"extensionsGallery": {"serviceUrl": "https://marketplace.visualstudio.com"}}`) + err := os.WriteFile(backupPath, originalContent, 0644) + require.NoError(t, err) + + // Create modified product.json + modifiedContent := []byte(`{"extensionsGallery": {"serviceUrl": "https://company.jfrog.io"}}`) + err = os.WriteFile(productPath, modifiedContent, 0644) + require.NoError(t, err) + + cmd := NewVscodeCommand("", productPath, "") + cmd.backupPath = backupPath + + err = cmd.restoreBackup() + assert.NoError(t, err) + + // Verify restoration + restoredContent, err := os.ReadFile(productPath) + require.NoError(t, err) + assert.Equal(t, originalContent, restoredContent) +} + +func TestVscodeCommand_ModifyProductJson_ValidFile(t *testing.T) { + // Create temporary product.json file + tempDir := t.TempDir() + productPath := filepath.Join(tempDir, "product.json") + + // Create a valid product.json + productContent := map[string]interface{}{ + "extensionsGallery": map[string]interface{}{ + "serviceUrl": "https://marketplace.visualstudio.com/_apis/public/gallery", + }, + "nameShort": "Code", + "version": "1.70.0", + } + + jsonData, err := json.Marshal(productContent) + require.NoError(t, err) + + err = os.WriteFile(productPath, jsonData, 0644) + require.NoError(t, err) + + cmd := NewVscodeCommand("", productPath, "") + newServiceURL := "https://company.jfrog.io/artifactory/api/vscodeextensions/repo/_apis/public/gallery" + + err = cmd.modifyProductJson(newServiceURL) + assert.NoError(t, err) + + // Verify backup was created (in JFrog backup directory) + assert.NotEmpty(t, cmd.backupPath) + assert.FileExists(t, cmd.backupPath) +} + +func TestVscodeCommand_ModifyProductJson_NonExistentFile(t *testing.T) { + cmd := NewVscodeCommand("", "/non/existent/path/product.json", "") + newServiceURL := "https://company.jfrog.io/artifactory/api/vscodeextensions/repo/_apis/public/gallery" + + err := cmd.modifyProductJson(newServiceURL) + assert.Error(t, err) +} + +func TestVscodeCommand_GetManualSetupInstructions(t *testing.T) { + cmd := NewVscodeCommand("", "", "") + serviceURL := "https://company.jfrog.io/artifactory/api/vscodeextensions/repo/_apis/public/gallery" + + instructions := cmd.getManualSetupInstructions(serviceURL) + + assert.NotEmpty(t, instructions) + assert.Contains(t, instructions, serviceURL) + assert.Contains(t, instructions, "product.json") + + // Should contain platform-specific instructions + switch runtime.GOOS { + case "darwin": + assert.Contains(t, instructions, "Applications") + case "windows": + assert.Contains(t, instructions, "resources") + case "linux": + assert.Contains(t, instructions, "usr/share") + } +} + +func TestVscodeCommand_ValidateRepository_NoServerDetails(t *testing.T) { + cmd := NewVscodeCommand("", "", "repo") + + // Should return error when no server details are set + err := cmd.validateRepository() + assert.Error(t, err) + assert.Contains(t, err.Error(), "server details not configured") +} + +func TestVscodeCommand_HandlePermissionError_macOS(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("macOS-specific test") + } + + cmd := NewVscodeCommand("https://company.jfrog.io/artifactory/api/vscodeextensions/repo/_apis/public/gallery", + "/Applications/Visual Studio Code.app/Contents/Resources/app/product.json", "") + + err := cmd.handlePermissionError() + assert.Error(t, err) + assert.Contains(t, err.Error(), "sudo") + assert.Contains(t, err.Error(), "elevated privileges") + assert.Contains(t, err.Error(), "/Applications/") +} + +// Benchmark tests for performance +func BenchmarkVscodeCommand_DetectVSCodeInstallation(b *testing.B) { + cmd := NewVscodeCommand("", "", "") + + for i := 0; i < b.N; i++ { + _, _ = cmd.detectVSCodeInstallation() + } +} diff --git a/artifactory/commands/npm/npmpublish.go b/artifactory/commands/npm/npmpublish.go index dc632d10..73f59969 100644 --- a/artifactory/commands/npm/npmpublish.go +++ b/artifactory/commands/npm/npmpublish.go @@ -3,6 +3,8 @@ package npm 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" @@ -11,7 +13,6 @@ import ( 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" - "strings" ) type npmPublish struct { diff --git a/cli/cli.go b/cli/cli.go index 5dd15255..d777881b 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -2,6 +2,9 @@ package cli import ( artifactoryCLI "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide/jetbrains" + "github.com/jfrog/jfrog-cli-artifactory/artifactory/cli/ide/vscode" distributionCLI "github.com/jfrog/jfrog-cli-artifactory/distribution/cli" evidenceCLI "github.com/jfrog/jfrog-cli-artifactory/evidence/cli" "github.com/jfrog/jfrog-cli-artifactory/lifecycle" @@ -33,5 +36,27 @@ func GetJfrogCliArtifactoryApp() components.App { Category: "Command Namespaces", }) app.Commands = append(app.Commands, lifecycle.GetCommands()...) + + // Add IDE commands as top-level commands + app.Commands = append(app.Commands, getTopLevelIDECommands()...) + return app } + +// getTopLevelIDECommands returns IDE commands configured for top-level access +func getTopLevelIDECommands() []components.Command { + // Get the original IDE commands + vscodeCommands := vscode.GetCommands() + jetbrainsCommands := jetbrains.GetCommands() + + // Use centralized descriptions + if len(vscodeCommands) > 0 { + vscodeCommands[0].Description = ide.VscodeConfigDescription + } + if len(jetbrainsCommands) > 0 { + jetbrainsCommands[0].Description = ide.JetbrainsConfigDescription + jetbrainsCommands[0].Aliases = append(jetbrainsCommands[0].Aliases, "jb") + } + + return append(vscodeCommands, jetbrainsCommands...) +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 00000000..09c36aaf --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "testing" + + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/stretchr/testify/assert" +) + +func TestGetJfrogCliArtifactoryApp(t *testing.T) { + app := GetJfrogCliArtifactoryApp() + + // Verify app has top-level IDE commands + ideCommands := []string{"vscode-config", "jetbrains-config"} + for _, cmdName := range ideCommands { + cmd := findCommandByName(app.Commands, cmdName) + assert.NotNil(t, cmd, "Top-level command %s should be found", cmdName) + } + + // Verify rt namespace doesn't have IDE commands anymore + rtNamespace := findNamespaceByName(app.Subcommands, "rt") + assert.NotNil(t, rtNamespace, "rt namespace should exist") + + rtCommands := []string{"vscode-config", "jetbrains-config"} + for _, cmdName := range rtCommands { + cmd := findCommandByName(rtNamespace.Commands, cmdName) + assert.Nil(t, cmd, "rt namespace should not contain %s command", cmdName) + } +} + +// Helper function to find a command by name +func findCommandByName(commands []components.Command, name string) *components.Command { + for i := range commands { + if commands[i].Name == name { + return &commands[i] + } + } + return nil +} + +// Helper function to find a namespace by name +func findNamespaceByName(namespaces []components.Namespace, name string) *components.Namespace { + for i := range namespaces { + if namespaces[i].Name == name { + return &namespaces[i] + } + } + return nil +}