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>
386 lines
10 KiB
Go
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
|
|
}
|