Files
2026-08-05 10:57:24 +01:00

170 lines
4.6 KiB
Go

package installer
import (
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// GithubReleaseConfig configures a GitHub release download.
type GithubReleaseConfig struct {
Owner string // e.g. "rust-lang"
Repo string // e.g. "rust-analyzer"
AssetPattern string // e.g. "rust-analyzer-{target}.gz"
Targets map[string]string // "darwin/arm64" -> "aarch64-apple-darwin"
}
// GithubReleaseStrategy downloads prebuilt binaries from GitHub releases.
type GithubReleaseStrategy struct {
binDir string
binary string // "rust-analyzer"
config GithubReleaseConfig
logger *logger.Logger
}
// NewGithubReleaseStrategy creates a new GitHub release download strategy.
func NewGithubReleaseStrategy(binDir, binary string, config GithubReleaseConfig, log *logger.Logger) *GithubReleaseStrategy {
return &GithubReleaseStrategy{
binDir: binDir,
binary: binary,
config: config,
logger: log,
}
}
func (s *GithubReleaseStrategy) Name() string {
return fmt.Sprintf("github release %s/%s", s.config.Owner, s.config.Repo)
}
func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, error) {
// Resolve target from runtime OS/ARCH
targetKey := runtime.GOOS + "/" + runtime.GOARCH
target, ok := s.config.Targets[targetKey]
if !ok {
return nil, fmt.Errorf("unsupported platform: %s", targetKey)
}
// Build download URL
asset := strings.Replace(s.config.AssetPattern, "{target}", target, 1)
url := fmt.Sprintf("https://github.com/%s/%s/releases/latest/download/%s",
s.config.Owner, s.config.Repo, asset)
s.logger.Info("downloading from GitHub releases",
zap.String("url", url),
zap.String("target", target))
// Download
resp, err := getDownload(ctx, url)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Ensure bin directory exists
if err := os.MkdirAll(s.binDir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
}
outPath := filepath.Join(s.binDir, s.binary)
if err := writeReleaseAsset(ctx, resp.Body, asset, outPath); err != nil {
return nil, err
}
s.logger.Info("GitHub release download completed", zap.String("binary", outPath))
return &InstallResult{
BinaryPath: outPath,
}, nil
}
func writeReleaseAsset(ctx context.Context, body io.Reader, asset, outPath string) error {
tempFile, err := os.CreateTemp(filepath.Dir(outPath), "."+filepath.Base(outPath)+".tmp-*")
if err != nil {
return fmt.Errorf("failed to create temporary download: %w", err)
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("failed to close temporary download: %w", err)
}
defer func() { _ = os.Remove(tempPath) }()
reader := &contextReader{ctx: ctx, reader: body}
if strings.HasSuffix(asset, ".gz") {
err = WriteGzipBody(reader, tempPath)
} else {
err = WriteBody(reader, tempPath)
}
if err != nil {
return err
}
if err := ctx.Err(); err != nil {
return err
}
if err := os.Chmod(tempPath, 0o755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)
}
if err := os.Rename(tempPath, outPath); err != nil {
return fmt.Errorf("failed to publish downloaded binary: %w", err)
}
return nil
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r *contextReader) Read(data []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(data)
}
// WriteGzipBody decompresses a gzip reader into outPath.
func WriteGzipBody(body io.Reader, outPath string) error {
gzReader, err := gzip.NewReader(body)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() { _ = gzReader.Close() }()
outFile, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = outFile.Close() }()
if _, err := io.Copy(outFile, gzReader); err != nil {
return fmt.Errorf("failed to decompress: %w", err)
}
return nil
}
// WriteBody writes a plain reader into outPath.
func WriteBody(body io.Reader, outPath string) error {
outFile, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = outFile.Close() }()
if _, err := io.Copy(outFile, body); err != nil {
return fmt.Errorf("failed to write binary: %w", err)
}
return nil
}