diff --git a/general/ide/README.md b/general/ide/README.md new file mode 100644 index 000000000..3475e07ec --- /dev/null +++ b/general/ide/README.md @@ -0,0 +1,88 @@ +# JFrog CLI IDE Configuration + +Automates JFrog Artifactory repository configuration for IDEs. + +## Features + +- **VSCode**: Configures extension gallery to use JFrog Artifactory +- **JetBrains**: Configures plugin repositories for all JetBrains IDEs +- **Cross-platform**: Windows, macOS, Linux support +- **Automatic backup**: Creates backups before modifications +- **Auto-detection**: Finds IDE installations automatically + +## Commands + +### VSCode +```bash +# Configure VSCode extensions repository +jf vscode config --repo= --artifactory-url= + +# Install VSCode extension from repository +jf vscode install --publisher= --extension-name= --repo= [--version=] [--artifactory-url=] + +# Examples +jf vscode config --repo=vscode-extensions --artifactory-url=https://myartifactory.com/ +sudo jf vscode config --repo=vscode-extensions --artifactory-url=https://myartifactory.com/ # macOS/Linux + +# Install specific extension +jf vscode install --publisher=microsoft --extension-name=vscode-python --repo=vscode-extensions +jf vscode install --publisher=ms-python --extension-name=python --version=2023.12.0 --repo=vscode-extensions +``` + +### JetBrains +```bash +# Configure JetBrains plugin repository +jf jetbrains config --repo= --artifactory-url= + +# Examples +jf jetbrains config --repo=jetbrains-plugins --artifactory-url=https://myartifactory.com/ +``` + +## How It Works + +### VSCode +**Configuration:** +1. Detects VSCode installation (`product.json` location) +2. Creates backup in `~/.jfrog/backup/ide/vscode/` +3. Modifies `extensionsGallery.serviceUrl` using: + - **macOS/Linux**: `sed` command + - **Windows**: PowerShell regex replacement +4. Preserves all other VSCode configuration + +**Extension Installation:** +1. Validates extension exists in Artifactory repository +2. Downloads extension package (.vsix) from JFrog Artifactory +3. Installs extension using VSCode CLI (`code --install-extension`) +4. Supports specific version installation or latest version +5. Cross-platform support (Windows/macOS/Linux) + +### JetBrains +1. Scans for JetBrains IDE configurations +2. Creates backups in `~/.jfrog/backup/ide/jetbrains/` +3. Updates `idea.properties` files with repository URLs +4. Supports all JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.) + +## Platform Requirements + +- **macOS**: Requires `sudo` for system-installed IDEs +- **Windows**: Requires "Run as Administrator" +- **Linux**: Requires `sudo` for system-installed IDEs + +## File Locations + +### VSCode +- **macOS**: `/Applications/Visual Studio Code.app/Contents/Resources/app/product.json` +- **Windows**: `%LOCALAPPDATA%\Programs\Microsoft VS Code\resources\app\product.json` +- **Linux**: `/usr/share/code/resources/app/product.json` + +### JetBrains +- **macOS**: `~/Library/Application Support/JetBrains/` +- **Windows**: `%APPDATA%\JetBrains\` +- **Linux**: `~/.config/JetBrains/` + +## Backup & Recovery + +- Automatic backups created before modifications +- Located in `~/.jfrog/backup/ide/` +- Automatic restore on failure +- Manual restore: copy backup file back to original location \ No newline at end of file diff --git a/general/ide/jetbrains/jetbrains.go b/general/ide/jetbrains/jetbrains.go new file mode 100644 index 000000000..7613abe24 --- /dev/null +++ b/general/ide/jetbrains/jetbrains.go @@ -0,0 +1,396 @@ +package jetbrains + +import ( + "bufio" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "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 { + repoKey string + artifactoryURL string + detectedIDEs []IDEInstallation + backupPaths map[string]string +} + +// 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(repoKey, artifactoryURL string) *JetbrainsCommand { + return &JetbrainsCommand{ + repoKey: repoKey, + artifactoryURL: artifactoryURL, + backupPaths: make(map[string]string), + } +} + +// Run executes the JetBrains configuration command +func (jc *JetbrainsCommand) Run() error { + log.Info("Configuring JetBrains IDEs plugin repository...") + + var repoURL string + if jc.repoKey == "" { + repoURL = jc.artifactoryURL + } else { + if jc.artifactoryURL == "" { + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return errorutils.CheckError(fmt.Errorf("failed to get default server configuration: %w", err)) + } + if serverDetails == nil { + return errorutils.CheckError(fmt.Errorf("no default server configuration found. Please configure JFrog CLI or provide --artifactory-url")) + } + jc.artifactoryURL = serverDetails.GetUrl() + } + repoURL = jc.buildRepositoryURL() + } + + if err := jc.validateRepository(repoURL); err != nil { + return errorutils.CheckError(fmt.Errorf("repository validation failed: %w", err)) + } + + 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(repoURL))) + } + + if len(jc.detectedIDEs) == 0 { + return errorutils.CheckError(fmt.Errorf("no JetBrains IDEs found\n\nManual setup instructions:\n%s", jc.getManualSetupInstructions(repoURL))) + } + + log.Info("Found", len(jc.detectedIDEs), "JetBrains IDE installation(s):") + for _, ide := range jc.detectedIDEs { + log.Info(" " + ide.Name + " " + ide.Version) + } + + 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, repoURL); 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(repoURL))) + } + + log.Info("Successfully configured", modifiedCount, "out of", len(jc.detectedIDEs), "JetBrains IDE(s)") + log.Info("Repository URL:", repoURL) + log.Info("Please restart your JetBrains IDEs to apply changes") + + return nil +} + +// buildRepositoryURL constructs the complete repository URL +func (jc *JetbrainsCommand) buildRepositoryURL() string { + baseURL := strings.TrimSuffix(jc.artifactoryURL, "/") + return fmt.Sprintf("%s/artifactory/api/jetbrainsplugins/%s", baseURL, jc.repoKey) +} + +// validateRepository checks if the repository is accessible +func (jc *JetbrainsCommand) validateRepository(repoURL string) error { + log.Info("Validating repository...") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(repoURL) + if err != nil { + return fmt.Errorf("failed to connect to repository: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("repository not found (404). Please verify the repository key '%s' exists", jc.repoKey) + } + if resp.StatusCode >= 400 && resp.StatusCode != http.StatusUnauthorized { + return fmt.Errorf("repository returned status %d. Please verify the repository is accessible", resp.StatusCode) + } + + 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": + configBasePath = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "JetBrains") + case "windows": + configBasePath = filepath.Join(os.Getenv("APPDATA"), "JetBrains") + case "linux": + configBasePath = filepath.Join(os.Getenv("HOME"), ".config", "JetBrains") + // Also check legacy location + 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, repoURL 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", repoURL)) + 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", repoURL)) + 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(repoURL 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" + } + + instructions := fmt.Sprintf(` +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 +`, configPath, repoURL, repoURL) + + return instructions +} diff --git a/general/ide/vscode/vscode.go b/general/ide/vscode/vscode.go new file mode 100644 index 000000000..9790dd6ec --- /dev/null +++ b/general/ide/vscode/vscode.go @@ -0,0 +1,565 @@ +package vscode + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "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 { + repoKey string + artifactoryURL string + productPath string + backupPath string +} + +// VscodeInstallCommand represents the VSCode extension installation command +type VscodeInstallCommand struct { + repoKey string + artifactoryURL string + publisher string + extensionName string + version string +} + +// Note: We use sed for direct modification, so we don't need JSON struct definitions + +// NewVscodeCommand creates a new VSCode configuration command +func NewVscodeCommand(repoKey, artifactoryURL, productPath string) *VscodeCommand { + return &VscodeCommand{ + repoKey: repoKey, + artifactoryURL: artifactoryURL, + productPath: productPath, + } +} + +// NewVscodeInstallCommand creates a new VSCode extension installation command +func NewVscodeInstallCommand(repoKey, artifactoryURL, publisher, extensionName, version string) *VscodeInstallCommand { + return &VscodeInstallCommand{ + repoKey: repoKey, + artifactoryURL: artifactoryURL, + publisher: publisher, + extensionName: extensionName, + version: version, + } +} + +// Run executes the VSCode configuration command +func (vc *VscodeCommand) Run() error { + log.Info("Configuring VSCode extensions repository...") + + var repoURL string + if vc.repoKey == "" { + repoURL = vc.artifactoryURL + } else { + if vc.artifactoryURL == "" { + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return errorutils.CheckError(fmt.Errorf("failed to get default server configuration: %w", err)) + } + if serverDetails == nil { + return errorutils.CheckError(fmt.Errorf("no default server configuration found. Please configure JFrog CLI or provide --artifactory-url")) + } + vc.artifactoryURL = serverDetails.GetUrl() + } + repoURL = vc.buildRepositoryURL() + } + + if err := vc.validateRepository(repoURL); err != nil { + return errorutils.CheckError(fmt.Errorf("repository validation failed: %w", err)) + } + + 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(repoURL))) + } + vc.productPath = detectedPath + log.Info("Detected VSCode at:", vc.productPath) + } + + if err := vc.modifyProductJson(repoURL); 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(repoURL))) + } + + log.Info("VSCode configuration updated successfully") + log.Info("Repository URL:", repoURL) + log.Info("Please restart VSCode to apply changes") + + return nil +} + +// buildRepositoryURL constructs the complete repository URL +func (vc *VscodeCommand) buildRepositoryURL() string { + baseURL := strings.TrimSuffix(vc.artifactoryURL, "/") + return fmt.Sprintf("%s/artifactory/api/vscodeextensions/%s/_apis/public/gallery", baseURL, vc.repoKey) +} + +// 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/") { + // Get current user info for better error message + userInfo := "the current user" + if user := os.Getenv("USER"); user != "" { + userInfo = user + } + + return fmt.Errorf(`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 config '%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/`, vc.artifactoryURL, vc.productPath, userInfo) + } + + return fmt.Errorf(`insufficient permissions to modify VSCode configuration. + +To fix this, try running the command with elevated privileges: + sudo jf vscode config '%s' + +Or use the manual setup instructions provided in the error output.`, vc.artifactoryURL) +} + +// validateRepository checks if the repository is accessible +func (vc *VscodeCommand) validateRepository(repoURL string) error { + log.Info("Validating repository...") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(repoURL) + if err != nil { + log.Warn("Could not validate repository connection:", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + log.Warn("Repository not found (404). Please verify the repository exists") + return nil + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + log.Warn("Repository requires authentication") + return nil + } + if resp.StatusCode >= 400 { + log.Warn("Repository returned status", resp.StatusCode) + return nil + } + + log.Info("Repository validation successful") + return nil +} + +// 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 uses sed for direct in-place modification - simpler and more efficient +func (vc *VscodeCommand) modifyWithSed(repoURL string) error { + // Escape special characters in the URL for sed + escapedURL := strings.ReplaceAll(repoURL, "/", "\\/") + escapedURL = strings.ReplaceAll(escapedURL, "&", "\\&") + + // Create sed command to find and replace serviceUrl + // This regex looks for "serviceUrl": "any-content" and replaces with new URL + sedPattern := fmt.Sprintf(`s#"serviceUrl": "[^"]*"#"serviceUrl": "%s"#`, escapedURL) + + var cmd *exec.Cmd + if runtime.GOOS == "darwin" { + // macOS sed needs empty string for -i flag + cmd = exec.Command("sudo", "sed", "-i", "", sedPattern, vc.productPath) + } else { + // Linux sed + cmd = exec.Command("sudo", "sed", "-i", sedPattern, vc.productPath) + } + + cmd.Stdin = os.Stdin // Allow interactive sudo prompt if needed + + if output, err := cmd.CombinedOutput(); err != nil { + if strings.Contains(string(output), "operation not permitted") { + return fmt.Errorf("SIP protection prevents modification") + } + return fmt.Errorf("failed to modify product.json with sed: %w\nOutput: %s", err, string(output)) + } + + if err := vc.verifyModification(repoURL); err != nil { + return fmt.Errorf("modification verification failed: %w", err) + } + return nil +} + +// modifyWithPowerShell uses PowerShell for Windows file modification +func (vc *VscodeCommand) modifyWithPowerShell(repoURL string) error { + // Escape quotes for PowerShell + escapedURL := strings.ReplaceAll(repoURL, `"`, `\"`) + + // PowerShell command to replace serviceUrl in the JSON file + // 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) + cmd.Stdin = os.Stdin + + 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)) + } + + if err := vc.verifyModification(repoURL); err != nil { + return fmt.Errorf("modification verification failed: %w", err) + } + return nil +} + +// verifyModification checks that the serviceUrl was actually changed +func (vc *VscodeCommand) verifyModification(expectedURL string) error { + data, err := os.ReadFile(vc.productPath) + if err != nil { + return fmt.Errorf("failed to read file for verification: %w", err) + } + + if !strings.Contains(string(data), expectedURL) { + return fmt.Errorf("expected URL %s not found in modified file", expectedURL) + } + + return nil +} + +// getManualSetupInstructions returns manual setup instructions +func (vc *VscodeCommand) getManualSetupInstructions(repoURL string) string { + instructions := fmt.Sprintf(` +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 + +Repository URL: %s +`, repoURL, repoURL) + + return instructions +} + +// Run executes the VSCode extension installation command +func (vic *VscodeInstallCommand) Run() error { + log.Info("Installing VSCode extension from JFrog Artifactory...") + + var repoURL string + if vic.artifactoryURL == "" { + serverDetails, err := config.GetDefaultServerConf() + if err != nil { + return errorutils.CheckError(fmt.Errorf("failed to get default server configuration: %w", err)) + } + if serverDetails == nil { + return errorutils.CheckError(fmt.Errorf("no default server configuration found. Please configure JFrog CLI or provide --artifactory-url")) + } + vic.artifactoryURL = serverDetails.GetUrl() + } + repoURL = vic.buildExtensionURL() + + if err := vic.validateExtensionRepository(repoURL); err != nil { + return errorutils.CheckError(fmt.Errorf("repository validation failed: %w", err)) + } + + if err := vic.downloadAndInstallExtension(repoURL); err != nil { + return errorutils.CheckError(fmt.Errorf("failed to install extension: %w", err)) + } + + log.Info("Extension installed successfully") + log.Info("Publisher:", vic.publisher) + log.Info("Extension:", vic.extensionName) + if vic.version != "" { + log.Info("Version:", vic.version) + } + log.Info("Please restart VSCode to use the extension") + + return nil +} + +// buildExtensionURL constructs the extension download URL +func (vic *VscodeInstallCommand) buildExtensionURL() string { + baseURL := strings.TrimSuffix(vic.artifactoryURL, "/") + if vic.version != "" { + return fmt.Sprintf("%s/artifactory/api/vscodeextensions/%s/_apis/public/gallery/publishers/%s/extensions/%s/%s/vspackage", + baseURL, vic.repoKey, vic.publisher, vic.extensionName, vic.version) + } + return fmt.Sprintf("%s/artifactory/api/vscodeextensions/%s/_apis/public/gallery/publishers/%s/extensions/%s/latest/vspackage", + baseURL, vic.repoKey, vic.publisher, vic.extensionName) +} + +// validateExtensionRepository checks if the extension repository is accessible +func (vic *VscodeInstallCommand) validateExtensionRepository(repoURL string) error { + log.Info("Validating extension repository...") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Head(repoURL) + if err != nil { + log.Warn("Could not validate extension repository:", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("extension not found. Please verify publisher '%s' and extension '%s' exist in repository '%s'", vic.publisher, vic.extensionName, vic.repoKey) + } + if resp.StatusCode >= 400 { + log.Warn("Extension repository returned status", resp.StatusCode) + return nil + } + + log.Info("Extension repository validation successful") + return nil +} + +// downloadAndInstallExtension downloads and installs the VSCode extension +func (vic *VscodeInstallCommand) downloadAndInstallExtension(repoURL string) error { + log.Info("Downloading extension...") + + // Download the extension package + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Get(repoURL) + if err != nil { + return fmt.Errorf("failed to download extension: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download extension: HTTP %d", resp.StatusCode) + } + + // Create temporary file for the extension package + tempDir := os.TempDir() + extensionFileName := vic.publisher + "." + vic.extensionName + ".vsix" + if vic.version != "" { + extensionFileName = vic.publisher + "." + vic.extensionName + "-" + vic.version + ".vsix" + } + tempFile := filepath.Join(tempDir, extensionFileName) + + // Write the downloaded content to temp file + out, err := os.Create(tempFile) + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tempFile) // Clean up temp file + + _, err = io.Copy(out, resp.Body) + if closeErr := out.Close(); closeErr != nil { + return fmt.Errorf("failed to close temp file: %w", closeErr) + } + if err != nil { + return fmt.Errorf("failed to save extension package: %w", err) + } + + log.Info("Installing extension using VSCode CLI...") + + // Install extension using VSCode's CLI + var codeCmd string + if runtime.GOOS == "windows" { + codeCmd = "code.cmd" + } else { + codeCmd = "code" + } + + cmd := exec.Command(codeCmd, "--install-extension", tempFile, "--force") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to install extension via VSCode CLI: %w\nOutput: %s", err, string(output)) + } + + log.Info("Extension package installed via VSCode CLI") + return nil +}