diff --git a/.gitignore b/.gitignore index 1ebd21ae..6b64633d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,6 @@ go.work go.work.sum .idea/ +.vscode/ -cli-v2 \ No newline at end of file +cli-v2 diff --git a/cmd/analyze.go b/cmd/analyze.go index fcc7a0ae..02caf12e 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -205,7 +205,7 @@ var analyzeCmd = &cobra.Command{ } eslint := config.Config.Tools()["eslint"] - eslintInstallationDirectory := eslint.Info()["installDir"] + eslintInstallationDirectory := eslint.InstallDir nodeRuntime := config.Config.Runtimes()["node"] nodeBinary := nodeRuntime.Binaries["node"] @@ -220,4 +220,4 @@ var analyzeCmd = &cobra.Command{ tools.RunEslint(workDirectory, eslintInstallationDirectory, nodeBinary, args, autoFix, outputFile, outputFormat) }, -} +} \ No newline at end of file diff --git a/cmd/install.go b/cmd/install.go index 935a100c..cfb0e0fb 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,7 +2,6 @@ package cmd import ( cfg "codacy/cli-v2/config" - "fmt" "log" "github.com/spf13/cobra" @@ -33,18 +32,9 @@ func installRuntimes(config *cfg.ConfigType) { } func installTools(config *cfg.ConfigType) { - for _, tool := range config.Tools() { - switch tool.Name() { - case "eslint": - // eslint needs node runtime - nodeRuntime := config.Runtimes()["node"] - err := cfg.InstallEslint(nodeRuntime, tool, registry) - if err != nil { - fmt.Println(err.Error()) - log.Fatal(err) - } - default: - log.Fatal("Unknown tool:", tool.Name()) - } + // Use the new tools-installer instead of manual installation + err := cfg.InstallTools() + if err != nil { + log.Fatal(err) } -} +} \ No newline at end of file diff --git a/config-file/configFile.go b/config-file/configFile.go index 9cb14be5..0a5a144e 100644 --- a/config-file/configFile.go +++ b/config-file/configFile.go @@ -36,12 +36,22 @@ func parseConfigFile(configContents []byte) error { return err } + // Convert the tool strings to ToolConfig objects + toolConfigs := make([]plugins.ToolConfig, 0, len(configFile.TOOLS)) for _, tl := range configFile.TOOLS { ct, err := parseConfigTool(tl) if err != nil { return err } - config.Config.AddTool(config.NewRuntime(ct.name, ct.version)) + toolConfigs = append(toolConfigs, plugins.ToolConfig{ + Name: ct.name, + Version: ct.version, + }) + } + + // Add all tools at once + if err := config.Config.AddTools(toolConfigs); err != nil { + return err } return nil diff --git a/config/config.go b/config/config.go index 875bdf49..12a55480 100644 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,7 @@ type ConfigType struct { projectConfigFile string runtimes map[string]*plugins.RuntimeInfo - tools map[string]*Runtime + tools map[string]*plugins.ToolInfo } func (c *ConfigType) HomePath() string { @@ -63,13 +63,23 @@ func (c *ConfigType) AddRuntimes(configs []plugins.RuntimeConfig) error { return nil } -// TODO do inheritance with tool -func (c *ConfigType) Tools() map[string]*Runtime { +func (c *ConfigType) Tools() map[string]*plugins.ToolInfo { return c.tools } -func (c *ConfigType) AddTool(t *Runtime) { - c.tools[t.Name()] = t +func (c *ConfigType) AddTools(configs []plugins.ToolConfig) error { + // Process the tool configurations using the plugins.ProcessTools function + toolInfoMap, err := plugins.ProcessTools(configs, c.toolsDirectory) + if err != nil { + return err + } + + // Store the tool information in the config + for name, info := range toolInfoMap { + c.tools[name] = info + } + + return nil } func (c *ConfigType) initCodacyDirs() { @@ -117,7 +127,7 @@ func Init() { Config.initCodacyDirs() Config.runtimes = make(map[string]*plugins.RuntimeInfo) - Config.tools = make(map[string]*Runtime) + Config.tools = make(map[string]*plugins.ToolInfo) } // Global singleton config-file diff --git a/config/eslint-utils.go b/config/eslint-utils.go deleted file mode 100644 index a3c68242..00000000 --- a/config/eslint-utils.go +++ /dev/null @@ -1,50 +0,0 @@ -package config - -import ( - "fmt" - "log" - "os/exec" - "path" - "codacy/cli-v2/plugins" -) - -func genInfoEslint(r *Runtime) map[string]string { - eslintFolder := fmt.Sprintf("%s@%s", r.Name(), r.Version()) - installDir := path.Join(Config.ToolsDirectory(), eslintFolder) - - return map[string]string{ - "installDir": installDir, - "eslint": path.Join(installDir, "node_modules", ".bin", "eslint"), - } -} - -/* - * This installs eslint using node's npm alongside its sarif extension - */ -func InstallEslint(nodeRuntime *plugins.RuntimeInfo, eslint *Runtime, registry string) error { - log.Println("Installing ESLint") - - eslintInstallArg := fmt.Sprintf("%s@%s", eslint.Name(), eslint.Version()) - if registry != "" { - fmt.Println("Using registry:", registry) - configCmd := exec.Command(nodeRuntime.Binaries["npm"], "config", "set", "registry", registry) - if configOut, err := configCmd.Output(); err != nil { - fmt.Println("Error setting npm registry:", err) - fmt.Println(string(configOut)) - return err - } - } - cmd := exec.Command(nodeRuntime.Binaries["npm"], "install", "--prefix", eslint.Info()["installDir"], - eslintInstallArg, "@microsoft/eslint-formatter-sarif") - fmt.Println(cmd.String()) - // to use the chdir command we needed to create the folder before, we can change this after - // cmd.Dir = eslintInstallationFolder - stdout, err := cmd.Output() - if err != nil { - fmt.Println("Error installing ESLint:", err) - fmt.Println(string(stdout)) - } - // Print the output - fmt.Println(string(stdout)) - return err -} diff --git a/config/runtime.go b/config/runtime.go deleted file mode 100644 index ff87e3ba..00000000 --- a/config/runtime.go +++ /dev/null @@ -1,45 +0,0 @@ -package config - -import ( - "fmt" -) - -// Note that this is only used by tools -type Runtime struct { - name string - version string - info map[string]string -} - -func (r *Runtime) Name() string { - return r.name -} - -func (r *Runtime) Version() string { - return r.version -} - -func (r *Runtime) FullName() string { - return fmt.Sprintf("%s-%s", r.name, r.version) -} - -func (r *Runtime) Info() map[string]string { - return r.info -} - -// populateInfo populates the runtime info -func (r *Runtime) populateInfo() { - switch r.Name() { - case "eslint": - r.info = genInfoEslint(r) - } -} - -func NewRuntime(name string, version string) *Runtime { - r := Runtime{ - name: name, - version: version, - } - r.populateInfo() - return &r -} diff --git a/config/tools-installer.go b/config/tools-installer.go new file mode 100644 index 00000000..eb194168 --- /dev/null +++ b/config/tools-installer.go @@ -0,0 +1,127 @@ +package config + +import ( + "bytes" + "codacy/cli-v2/plugins" + "fmt" + "log" + "os" + "os/exec" + "strings" + "text/template" +) + +// InstallTools installs all tools defined in the configuration +func InstallTools() error { + for name, toolInfo := range Config.Tools() { + err := InstallTool(name, toolInfo) + if err != nil { + return fmt.Errorf("failed to install tool %s: %w", name, err) + } + } + return nil +} + +// InstallTool installs a specific tool +func InstallTool(name string, toolInfo *plugins.ToolInfo) error { + // Check if the tool is already installed + if isToolInstalled(toolInfo) { + fmt.Printf("Tool %s v%s is already installed\n", name, toolInfo.Version) + return nil + } + + // Get the runtime for this tool + runtimeInfo, ok := Config.Runtimes()[toolInfo.Runtime] + if !ok { + return fmt.Errorf("required runtime %s not found for tool %s", toolInfo.Runtime, name) + } + + // Make sure the installation directory exists + err := os.MkdirAll(toolInfo.InstallDir, 0755) + if err != nil { + return fmt.Errorf("failed to create installation directory: %w", err) + } + + // Prepare template data + templateData := map[string]string{ + "InstallDir": toolInfo.InstallDir, + "PackageName": toolInfo.Name, + "Version": toolInfo.Version, + "Registry": "", // TODO: Get registry from config + } + + // Get package manager binary based on the tool configuration + packageManagerName := toolInfo.PackageManager + packageManagerBinary, ok := runtimeInfo.Binaries[packageManagerName] + if !ok { + return fmt.Errorf("package manager binary %s not found in runtime %s", packageManagerName, toolInfo.Runtime) + } + + // Set registry if provided + if toolInfo.RegistryCommand != "" { + regCmd, err := executeToolTemplate(toolInfo.RegistryCommand, templateData) + if err != nil { + return fmt.Errorf("failed to prepare registry command: %w", err) + } + + if regCmd != "" { + registryCmd := exec.Command(packageManagerBinary, strings.Split(regCmd, " ")...) + if output, err := registryCmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to set registry: %s: %w", string(output), err) + } + } + } + + // Execute installation command + installCmd, err := executeToolTemplate(toolInfo.InstallCommand, templateData) + if err != nil { + return fmt.Errorf("failed to prepare install command: %w", err) + } + + // Execute the installation command using the package manager + cmd := exec.Command(packageManagerBinary, strings.Split(installCmd, " ")...) + + log.Printf("Installing %s v%s...\n", toolInfo.Name, toolInfo.Version) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to install tool: %s: %w", string(output), err) + } + + log.Printf("Successfully installed %s v%s\n", toolInfo.Name, toolInfo.Version) + return nil +} + +// isToolInstalled checks if a tool is already installed by checking for the binary +func isToolInstalled(toolInfo *plugins.ToolInfo) bool { + // If there are no binaries, check the install directory + if len(toolInfo.Binaries) == 0 { + _, err := os.Stat(toolInfo.InstallDir) + return err == nil + } + + // Check if at least one binary exists + for _, binaryPath := range toolInfo.Binaries { + _, err := os.Stat(binaryPath) + if err == nil { + return true + } + } + + return false +} + +// executeToolTemplate executes a template with the given data +func executeToolTemplate(tmplStr string, data map[string]string) (string, error) { + tmpl, err := template.New("command").Parse(tmplStr) + if err != nil { + return "", err + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, data) + if err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/config/tools-installer_test.go b/config/tools-installer_test.go new file mode 100644 index 00000000..abd76060 --- /dev/null +++ b/config/tools-installer_test.go @@ -0,0 +1,84 @@ +package config + +import ( + "codacy/cli-v2/plugins" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddTools(t *testing.T) { + // Set up a temporary config for testing + originalConfig := Config + defer func() { Config = originalConfig }() // Restore original config after test + + tempDir, err := os.MkdirTemp("", "codacy-tools-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Initialize config with test directories + Config = ConfigType{ + toolsDirectory: tempDir, + tools: make(map[string]*plugins.ToolInfo), + } + + // Create a list of tool configs for testing + configs := []plugins.ToolConfig{ + { + Name: "eslint", + Version: "8.38.0", + }, + } + + // Add tools to the config + err = Config.AddTools(configs) + assert.NoError(t, err) + + // Assert we have the expected tool in the config + assert.Contains(t, Config.Tools(), "eslint") + + // Get the eslint tool info + eslintInfo := Config.Tools()["eslint"] + + // Assert the basic tool info is correct + assert.Equal(t, "eslint", eslintInfo.Name) + assert.Equal(t, "8.38.0", eslintInfo.Version) + assert.Equal(t, "node", eslintInfo.Runtime) + + // Assert the install directory is correct + expectedInstallDir := filepath.Join(tempDir, "eslint@8.38.0") + assert.Equal(t, expectedInstallDir, eslintInfo.InstallDir) +} + +func TestExecuteToolTemplate(t *testing.T) { + // Test template execution with different data + templateStr := "install --prefix {{.InstallDir}} {{.PackageName}}@{{.Version}}" + data := map[string]string{ + "InstallDir": "/test/tools/eslint@8.38.0", + "PackageName": "eslint", + "Version": "8.38.0", + } + + result, err := executeToolTemplate(templateStr, data) + assert.NoError(t, err) + assert.Equal(t, "install --prefix /test/tools/eslint@8.38.0 eslint@8.38.0", result) + + // Test conditional registry template + registryTemplateStr := "{{if .Registry}}config set registry {{.Registry}}{{end}}" + + // With registry + dataWithRegistry := map[string]string{ + "Registry": "https://registry.npmjs.org/", + } + resultWithRegistry, err := executeToolTemplate(registryTemplateStr, dataWithRegistry) + assert.NoError(t, err) + assert.Equal(t, "config set registry https://registry.npmjs.org/", resultWithRegistry) + + // Without registry + dataWithoutRegistry := map[string]string{} + resultWithoutRegistry, err := executeToolTemplate(registryTemplateStr, dataWithoutRegistry) + assert.NoError(t, err) + assert.Equal(t, "", resultWithoutRegistry) +} \ No newline at end of file diff --git a/go.mod b/go.mod index 05bccbff..bef7d5d0 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module codacy/cli-v2 go 1.22.3 -require ( - github.com/google/uuid v1.6.0 - github.com/spf13/cobra v1.8.0 -) +require github.com/spf13/cobra v1.8.0 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 8edd242e..ab4ef807 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,6 @@ github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OI github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= diff --git a/plugins/tool-utils.go b/plugins/tool-utils.go new file mode 100644 index 00000000..ab1009c7 --- /dev/null +++ b/plugins/tool-utils.go @@ -0,0 +1,146 @@ +package plugins + +import ( + "embed" + "fmt" + "path" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +//go:embed tools/*/plugin.yaml +var toolsFS embed.FS + +// ToolBinary represents a binary executable provided by the tool +type ToolBinary struct { + Name string `yaml:"name"` + Path string `yaml:"path"` +} + +// Formatter represents a supported output format +type Formatter struct { + Name string `yaml:"name"` + Flag string `yaml:"flag"` +} + +// InstallationConfig holds the installation configuration from the plugin.yaml +type InstallationConfig struct { + Command string `yaml:"command"` + RegistryTemplate string `yaml:"registry_template"` +} + +// OutputOptions holds configuration for output handling +type OutputOptions struct { + FileFlag string `yaml:"file_flag"` +} + +// AnalysisOptions holds configuration for analysis options +type AnalysisOptions struct { + AutofixFlag string `yaml:"autofix_flag"` + DefaultPath string `yaml:"default_path"` +} + +// RuntimeBinaries holds the mapping of runtime binary names +type RuntimeBinaries struct { + PackageManager string `yaml:"package_manager"` + Execution string `yaml:"execution"` +} + +// ToolPluginConfig holds the structure of the tool plugin.yaml file +type ToolPluginConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Runtime string `yaml:"runtime"` + RuntimeBinaries RuntimeBinaries `yaml:"runtime_binaries"` + Installation InstallationConfig `yaml:"installation"` + Binaries []ToolBinary `yaml:"binaries"` + Formatters []Formatter `yaml:"formatters"` + OutputOptions OutputOptions `yaml:"output_options"` + AnalysisOptions AnalysisOptions `yaml:"analysis_options"` +} + +// ToolConfig represents configuration for a tool +type ToolConfig struct { + Name string + Version string + Registry string +} + +// ToolInfo contains all processed information about a tool +type ToolInfo struct { + Name string + Version string + Runtime string + InstallDir string + Binaries map[string]string // Map of binary name to full path + Formatters map[string]string // Map of formatter name to flag + OutputFlag string + AutofixFlag string + DefaultPath string + // Runtime binaries + PackageManager string + ExecutionBinary string + // Installation info + InstallCommand string + RegistryCommand string +} + +// ProcessTools processes a list of tool configurations and returns a map of tool information +func ProcessTools(configs []ToolConfig, toolDir string) (map[string]*ToolInfo, error) { + result := make(map[string]*ToolInfo) + + for _, config := range configs { + // Load the tool plugin + pluginPath := filepath.Join("tools", config.Name, "plugin.yaml") + + // Read from embedded filesystem + data, err := toolsFS.ReadFile(pluginPath) + if err != nil { + return nil, fmt.Errorf("error reading plugin.yaml for %s: %w", config.Name, err) + } + + var pluginConfig ToolPluginConfig + err = yaml.Unmarshal(data, &pluginConfig) + if err != nil { + return nil, fmt.Errorf("error parsing plugin.yaml for %s: %w", config.Name, err) + } + + // Create the install directory path + installDir := path.Join(toolDir, fmt.Sprintf("%s@%s", config.Name, config.Version)) + + // Create ToolInfo with basic information + info := &ToolInfo{ + Name: config.Name, + Version: config.Version, + Runtime: pluginConfig.Runtime, + InstallDir: installDir, + Binaries: make(map[string]string), + Formatters: make(map[string]string), + OutputFlag: pluginConfig.OutputOptions.FileFlag, + AutofixFlag: pluginConfig.AnalysisOptions.AutofixFlag, + DefaultPath: pluginConfig.AnalysisOptions.DefaultPath, + // Store runtime binary information + PackageManager: pluginConfig.RuntimeBinaries.PackageManager, + ExecutionBinary: pluginConfig.RuntimeBinaries.Execution, + // Store raw command templates (processing will happen later) + InstallCommand: pluginConfig.Installation.Command, + RegistryCommand: pluginConfig.Installation.RegistryTemplate, + } + + // Process binary paths + for _, binary := range pluginConfig.Binaries { + binaryPath := path.Join(installDir, binary.Path) + info.Binaries[binary.Name] = binaryPath + } + + // Process formatters + for _, formatter := range pluginConfig.Formatters { + info.Formatters[formatter.Name] = formatter.Flag + } + + result[config.Name] = info + } + + return result, nil +} diff --git a/plugins/tool-utils_test.go b/plugins/tool-utils_test.go new file mode 100644 index 00000000..a6bd7661 --- /dev/null +++ b/plugins/tool-utils_test.go @@ -0,0 +1,68 @@ +package plugins + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProcessTools(t *testing.T) { + // Create a list of tool configs for testing + configs := []ToolConfig{ + { + Name: "eslint", + Version: "8.38.0", + }, + } + + // Define a test tool directory + toolDir := "/test/tools" + + // Process the tools + toolInfos, err := ProcessTools(configs, toolDir) + + // Assert no errors occurred + assert.NoError(t, err, "ProcessTools should not return an error") + + // Assert we have the expected tool in the results + assert.Contains(t, toolInfos, "eslint") + + // Get the eslint tool info + eslintInfo := toolInfos["eslint"] + + // Assert the basic tool info is correct + assert.Equal(t, "eslint", eslintInfo.Name) + assert.Equal(t, "8.38.0", eslintInfo.Version) + assert.Equal(t, "node", eslintInfo.Runtime) + + // Assert the install directory is correct + expectedInstallDir := filepath.Join(toolDir, "eslint@8.38.0") + assert.Equal(t, expectedInstallDir, eslintInfo.InstallDir) + + // Assert binary paths are correctly set + assert.NotNil(t, eslintInfo.Binaries) + assert.Greater(t, len(eslintInfo.Binaries), 0) + + // Check if eslint binary is present + eslintBinary := filepath.Join(expectedInstallDir, "node_modules/.bin/eslint") + assert.Equal(t, eslintBinary, eslintInfo.Binaries["eslint"]) + + // Assert formatters are correctly set + assert.NotNil(t, eslintInfo.Formatters) + assert.Greater(t, len(eslintInfo.Formatters), 0) + assert.Equal(t, "-f @microsoft/eslint-formatter-sarif", eslintInfo.Formatters["sarif"]) + + // Assert output and analysis options are correctly set + assert.Equal(t, "-o", eslintInfo.OutputFlag) + assert.Equal(t, "--fix", eslintInfo.AutofixFlag) + assert.Equal(t, ".", eslintInfo.DefaultPath) + + // Assert runtime binaries are correctly set + assert.Equal(t, "npm", eslintInfo.PackageManager) + assert.Equal(t, "node", eslintInfo.ExecutionBinary) + + // Assert installation command templates are correctly set + assert.Equal(t, "install --prefix {{.InstallDir}} {{.PackageName}}@{{.Version}} @microsoft/eslint-formatter-sarif", eslintInfo.InstallCommand) + assert.Equal(t, "{{if .Registry}}config set registry {{.Registry}}{{end}}", eslintInfo.RegistryCommand) +} diff --git a/plugins/tools/eslint/plugin.yaml b/plugins/tools/eslint/plugin.yaml new file mode 100644 index 00000000..d7af3231 --- /dev/null +++ b/plugins/tools/eslint/plugin.yaml @@ -0,0 +1,20 @@ +name: eslint +description: ESLint JavaScript linter +runtime: node +runtime_binaries: + package_manager: npm + execution: node +installation: + command: "install --prefix {{.InstallDir}} {{.PackageName}}@{{.Version}} @microsoft/eslint-formatter-sarif" + registry_template: "{{if .Registry}}config set registry {{.Registry}}{{end}}" +binaries: + - name: eslint + path: "node_modules/.bin/eslint" +formatters: + - name: sarif + flag: "-f @microsoft/eslint-formatter-sarif" +output_options: + file_flag: "-o" +analysis_options: + autofix_flag: "--fix" + default_path: "."