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>
This commit is contained in:
parent
f8e3266029
commit
c1399f5107
9 changed files with 596 additions and 102 deletions
386
tools.go
Normal file
386
tools.go
Normal file
|
@ -0,0 +1,386 @@
|
|||
// 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue