pluginctl/tools.go
Felipe Martin c1399f5107
Add tools command for direct binary downloads from GitHub releases
Introduces a new 'tools' command that installs development tools (golangci-lint, gotestsum)
by downloading pre-built binaries directly from GitHub releases instead of using 'go get -tool'.
This prevents modifications to plugin go.mod files and improves build reliability.

Features:
- Cross-platform support (Windows, macOS, Linux) with automatic architecture detection
- Version-specific binary naming with symlinks for easy access
- Configurable installation directory via --bin-dir flag
- Tar.gz archive extraction with binary validation
- Updated Makefile integration to use downloaded binaries

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-04 13:44:05 +02:00

386 lines
10 KiB
Go

// NOTE: We download tools directly from tarball/binary releases instead of using
// `go get -tool` to prevent modifications to plugin go.mod files on plugins.
package pluginctl
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
)
const (
defaultGolangciLintVersion = "v2.3.1"
defaultGotestsumVersion = "v1.7.0"
defaultBinDir = "./build/bin"
helpFlagLong = "--help"
helpFlagShort = "-h"
exeExtension = ".exe"
tempSuffix = "-temp"
// Platform constants.
platformDarwin = "darwin"
platformWindows = "windows"
platformLinux = "linux"
// Architecture constants.
archARM64 = "arm64"
archAMD64 = "amd64"
arch386 = "386"
// File permission constants.
dirPerm = 0750
filePerm = 0600
)
// ToolConfig represents configuration for downloading and installing a tool.
type ToolConfig struct {
Name string
Version string
GitHubRepo string
URLTemplate string
FilenameTemplate string
BinaryPath string // Path within archive (e.g., "bin/tool" or "tool")
}
var toolConfigs = map[string]ToolConfig{
"golangci-lint": {
Name: "golangci-lint",
Version: defaultGolangciLintVersion,
GitHubRepo: "golangci/golangci-lint",
URLTemplate: "https://github.com/{repo}/releases/download/{version}/" +
"golangci-lint-{version_no_v}-{os}-{arch}.tar.gz",
FilenameTemplate: "golangci-lint-{version_no_v}-{os}-{arch}.tar.gz",
BinaryPath: "golangci-lint-{version_no_v}-{os}-{arch}/golangci-lint",
},
"gotestsum": {
Name: "gotestsum",
Version: defaultGotestsumVersion,
GitHubRepo: "gotestyourself/gotestsum",
URLTemplate: "https://github.com/{repo}/releases/download/{version}/" +
"gotestsum_{version_no_v}_{os}_{arch}.tar.gz",
FilenameTemplate: "gotestsum_{version_no_v}_{os}_{arch}.tar.gz",
BinaryPath: "gotestsum",
},
}
func RunToolsCommand(args []string, pluginPath string) error {
if len(args) == 0 {
return showToolsUsage()
}
subcommand := args[0]
subcommandArgs := args[1:]
switch subcommand {
case "install":
return runToolsInstallCommand(subcommandArgs)
case "help", helpFlagLong, helpFlagShort:
return showToolsUsage()
default:
return fmt.Errorf("unknown tools subcommand: %s", subcommand)
}
}
func runToolsInstallCommand(args []string) error {
binDir := defaultBinDir
for i, arg := range args {
if arg == helpFlagLong || arg == helpFlagShort {
return showToolsInstallUsage()
}
if arg == "--bin-dir" && i+1 < len(args) {
binDir = args[i+1]
}
}
Logger.Info("Installing development tools...", "bin-dir", binDir)
if err := os.MkdirAll(binDir, dirPerm); err != nil {
return fmt.Errorf("failed to create bin directory: %w", err)
}
for toolName := range toolConfigs {
if err := installTool(toolName, binDir); err != nil {
return fmt.Errorf("failed to install %s: %w", toolName, err)
}
}
Logger.Info("All development tools installed successfully")
return nil
}
// getPlatform returns the platform string for tool downloads.
func getPlatform() string {
switch runtime.GOOS {
case platformDarwin:
return platformDarwin
case platformWindows:
return platformWindows
default:
return platformLinux
}
}
// getArchitecture returns the architecture string for tool downloads.
func getArchitecture() string {
switch runtime.GOARCH {
case archARM64:
return archARM64
case arch386:
return arch386
default:
return archAMD64
}
}
// expandTemplate replaces placeholders in template strings.
func expandTemplate(template string, config *ToolConfig, platform, arch string) string {
versionNoV := strings.TrimPrefix(config.Version, "v")
replacements := map[string]string{
"{repo}": config.GitHubRepo,
"{version}": config.Version,
"{version_no_v}": versionNoV,
"{os}": platform,
"{arch}": arch,
}
result := template
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// downloadAndExtractTool downloads and extracts a tool from GitHub releases.
func downloadAndExtractTool(config *ToolConfig, binDir string) error {
platform := getPlatform()
arch := getArchitecture()
downloadURL := expandTemplate(config.URLTemplate, config, platform, arch)
binaryPathInArchive := expandTemplate(config.BinaryPath, config, platform, arch)
Logger.Info("Downloading tool", "tool", config.Name, "url", downloadURL)
resp, err := downloadToolFromURL(downloadURL, config.Name) //nolint:gosec // Trusted source
if err != nil {
return err
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
Logger.Error("Failed to close response body", "error", closeErr)
}
}()
return extractToolFromArchive(resp.Body, config, binDir, binaryPathInArchive)
}
// downloadToolFromURL downloads a tool from the specified URL.
func downloadToolFromURL(downloadURL, toolName string) (*http.Response, error) {
resp, err := http.Get(downloadURL) //nolint:gosec,noctx // URL from trusted configuration
if err != nil {
return nil, fmt.Errorf("failed to download %s: %w", toolName, err)
}
if resp.StatusCode != http.StatusOK {
if closeErr := resp.Body.Close(); closeErr != nil {
Logger.Error("Failed to close response body", "error", closeErr)
}
return nil, fmt.Errorf("failed to download %s: HTTP %d", toolName, resp.StatusCode)
}
return resp, nil
}
// extractToolFromArchive extracts the tool binary from a tar.gz archive.
func extractToolFromArchive(reader io.Reader, config *ToolConfig, binDir, binaryPathInArchive string) error {
gzr, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("failed to create gzip reader for %s: %w", config.Name, err)
}
defer func() {
if closeErr := gzr.Close(); closeErr != nil {
Logger.Error("Failed to close gzip reader", "error", closeErr)
}
}()
tr := tar.NewReader(gzr)
// Create final binary path
binaryName := config.Name
finalBinaryPath := filepath.Join(binDir, fmt.Sprintf("%s-%s", config.Name, config.Version))
if runtime.GOOS == platformWindows {
binaryName += exeExtension
finalBinaryPath += exeExtension
}
paths := binaryPaths{
pathInArchive: binaryPathInArchive,
binaryName: binaryName,
finalPath: finalBinaryPath,
}
return extractBinaryFromTar(tr, config, binDir, paths)
}
// binaryPaths holds path information for binary extraction.
type binaryPaths struct {
pathInArchive string
binaryName string
finalPath string
}
// extractBinaryFromTar searches and extracts the binary from a tar archive.
func extractBinaryFromTar(tr *tar.Reader, config *ToolConfig, binDir string, paths binaryPaths) error {
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar archive for %s: %w", config.Name, err)
}
if isBinaryFile(header.Name, paths.pathInArchive, paths.binaryName, config.Name) {
return saveBinaryFile(tr, config, binDir, paths.finalPath)
}
}
return fmt.Errorf("%s binary not found in archive", config.Name)
}
// isBinaryFile checks if the file matches the binary we're looking for.
func isBinaryFile(fileName, binaryPathInArchive, binaryName, configName string) bool {
return fileName == binaryPathInArchive ||
strings.HasSuffix(fileName, "/"+binaryName) ||
strings.HasSuffix(fileName, configName)
}
// saveBinaryFile saves the binary from tar reader to disk.
func saveBinaryFile(tr *tar.Reader, config *ToolConfig, binDir, finalBinaryPath string) error {
tempPath := filepath.Join(binDir, config.Name+tempSuffix)
if runtime.GOOS == platformWindows {
tempPath += exeExtension
}
file, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, filePerm)
if err != nil {
return fmt.Errorf("failed to create temporary binary file for %s: %w", config.Name, err)
}
_, err = io.Copy(file, tr) //nolint:gosec // Archive from trusted source
if closeErr := file.Close(); closeErr != nil {
Logger.Error("Failed to close temp file", "error", closeErr)
}
if err != nil {
return fmt.Errorf("failed to write binary file for %s: %w", config.Name, err)
}
if err := os.Rename(tempPath, finalBinaryPath); err != nil {
return fmt.Errorf("failed to rename binary to final path for %s: %w", config.Name, err)
}
Logger.Info("Tool installed successfully", "tool", config.Name, "path", finalBinaryPath)
return nil
}
// installTool installs a single tool by name using its configuration.
func installTool(toolName, binDir string) error {
config, exists := toolConfigs[toolName]
if !exists {
return fmt.Errorf("unknown tool: %s", toolName)
}
binaryPath := filepath.Join(binDir, fmt.Sprintf("%s-%s", config.Name, config.Version))
symlinkPath := filepath.Join(binDir, config.Name)
if runtime.GOOS == platformWindows {
binaryPath += exeExtension
symlinkPath += exeExtension
}
if fileExists(binaryPath) {
return createSymlink(binaryPath, symlinkPath)
}
Logger.Info("Installing tool", "tool", config.Name, "version", config.Version)
if err := downloadAndExtractTool(&config, binDir); err != nil {
return err
}
return createSymlink(binaryPath, symlinkPath)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func createSymlink(target, link string) error {
if fileExists(link) {
if err := os.Remove(link); err != nil {
return fmt.Errorf("failed to remove existing symlink: %w", err)
}
}
targetRel, err := filepath.Rel(filepath.Dir(link), target)
if err != nil {
return fmt.Errorf("failed to calculate relative path: %w", err)
}
if err := os.Symlink(targetRel, link); err != nil {
return fmt.Errorf("failed to create symlink: %w", err)
}
Logger.Info("Created symlink", "target", target, "link", link)
return nil
}
func showToolsUsage() error {
usageText := `Tools command - Manage development tools
Usage:
pluginctl tools <subcommand> [options]
Subcommands:
install Install development tools (golangci-lint, gotestsum)
Use 'pluginctl tools <subcommand> --help' for detailed information about a subcommand.
`
Logger.Info(usageText)
return nil
}
func showToolsInstallUsage() error {
usageText := `Install development tools
Usage:
pluginctl tools install
Description:
Installs the following development tools to ./bin/ directory:
- golangci-lint ` + defaultGolangciLintVersion + `
- gotestsum ` + defaultGotestsumVersion + `
Tools are downloaded with version-specific names (e.g., golangci-lint-v2.3.1)
to allow version tracking and prevent unnecessary re-downloads.
Options:
--help, -h Show this help message
`
Logger.Info(usageText)
return nil
}