mcp-framework/update/update.go
2026-04-13 15:33:48 +02:00

371 lines
9.6 KiB
Go

package update
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
type Options struct {
Client *http.Client
CurrentVersion string
ExecutablePath string
LatestReleaseURL string
Stdout io.Writer
BinaryName string
ReleaseSource GitLabSource
GOOS string
GOARCH string
}
type GitLabSource struct {
BaseURL string
ProjectPath string
Token string
TokenHeader string
TokenEnvNames []string
}
type Auth struct {
Header string
Token string
}
type Release struct {
TagName string `json:"tag_name"`
Assets struct {
Links []ReleaseLink `json:"links"`
} `json:"assets"`
}
type ReleaseLink struct {
Name string `json:"name"`
URL string `json:"url"`
}
func Run(ctx context.Context, opts Options) error {
if opts.Stdout == nil {
opts.Stdout = io.Discard
}
if opts.Client == nil {
opts.Client = &http.Client{Timeout: 60 * time.Second}
}
if strings.TrimSpace(opts.CurrentVersion) == "" {
opts.CurrentVersion = "dev"
}
if strings.TrimSpace(opts.GOOS) == "" {
opts.GOOS = runtime.GOOS
}
if strings.TrimSpace(opts.GOARCH) == "" {
opts.GOARCH = runtime.GOARCH
}
source := normalizeSource(opts.ReleaseSource)
auth := ResolveGitLabAuth(source.Token, source)
targetPath, err := ResolveUpdateTarget(opts.ExecutablePath)
if err != nil {
return err
}
assetName, err := AssetName(opts.BinaryName, opts.GOOS, opts.GOARCH)
if err != nil {
return err
}
releaseURL := opts.LatestReleaseURL
if strings.TrimSpace(releaseURL) == "" {
releaseURL = LatestReleaseAPIURL(source)
}
release, err := FetchLatestRelease(ctx, opts.Client, releaseURL, auth, source)
if err != nil {
return err
}
if isCurrentRelease(opts.CurrentVersion, release.TagName) {
fmt.Fprintf(opts.Stdout, "Already up to date (%s)\n", release.TagName)
return nil
}
assetURL, err := release.AssetURL(assetName, releaseURL)
if err != nil {
return err
}
downloadPath, err := DownloadReleaseAsset(ctx, opts.Client, assetURL, targetPath, auth, source)
if err != nil {
return err
}
defer os.Remove(downloadPath)
if err := ReplaceExecutable(downloadPath, targetPath); err != nil {
return err
}
fmt.Fprintf(opts.Stdout, "Updated %s to %s\n", targetPath, release.TagName)
return nil
}
func ResolveGitLabAuth(explicitToken string, source GitLabSource) Auth {
source = normalizeSource(source)
if token := strings.TrimSpace(explicitToken); token != "" {
return Auth{Header: source.TokenHeader, Token: token}
}
for _, envName := range source.TokenEnvNames {
if token := strings.TrimSpace(os.Getenv(envName)); token != "" {
return Auth{Header: source.TokenHeader, Token: token}
}
}
return Auth{}
}
func LatestReleaseAPIURL(source GitLabSource) string {
source = normalizeSource(source)
return fmt.Sprintf(
"%s/api/v4/projects/%s/releases/permalink/latest",
source.BaseURL,
url.PathEscape(source.ProjectPath),
)
}
func ResolveUpdateTarget(explicitPath string) (string, error) {
targetPath := strings.TrimSpace(explicitPath)
if targetPath == "" {
var err error
targetPath, err = os.Executable()
if err != nil {
return "", fmt.Errorf("resolve executable path: %w", err)
}
}
resolvedPath, err := filepath.EvalSymlinks(targetPath)
if err != nil {
return "", fmt.Errorf("resolve executable symlink %q: %w", targetPath, err)
}
return resolvedPath, nil
}
func AssetName(binaryName, goos, goarch string) (string, error) {
name := strings.TrimSpace(binaryName)
if name == "" {
return "", errors.New("binary name must not be empty")
}
switch {
case goos == "darwin" && goarch == "amd64":
return name + "-darwin-amd64", nil
case goos == "darwin" && goarch == "arm64":
return name + "-darwin-arm64", nil
case goos == "linux" && goarch == "amd64":
return name + "-linux-amd64", nil
case goos == "windows" && goarch == "amd64":
return name + "-windows-amd64.exe", nil
default:
return "", fmt.Errorf("no release artifact for %s/%s", goos, goarch)
}
}
func FetchLatestRelease(ctx context.Context, client *http.Client, releaseURL string, auth Auth, source GitLabSource) (Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil)
if err != nil {
return Release{}, fmt.Errorf("build latest release request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "mcp updater")
auth.apply(req)
resp, err := client.Do(req)
if err != nil {
return Release{}, fmt.Errorf("fetch latest release metadata: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err := auth.maybeHint(resp.StatusCode, body, source); err != nil {
return Release{}, fmt.Errorf("fetch latest release metadata: %w", err)
}
return Release{}, fmt.Errorf(
"fetch latest release metadata: unexpected status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
}
var release Release
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return Release{}, fmt.Errorf("decode latest release metadata: %w", err)
}
if strings.TrimSpace(release.TagName) == "" {
return Release{}, errors.New("latest release metadata is missing tag_name")
}
return release, nil
}
func (r Release) AssetURL(assetName, releaseURL string) (string, error) {
for _, link := range r.Assets.Links {
if link.Name == assetName {
if strings.TrimSpace(link.URL) == "" {
return "", fmt.Errorf("release asset %q has no URL", assetName)
}
parsed, err := url.Parse(link.URL)
if err != nil {
return "", fmt.Errorf("parse release asset URL %q: %w", link.URL, err)
}
if parsed.IsAbs() {
return parsed.String(), nil
}
baseURL, err := url.Parse(releaseURL)
if err != nil {
return "", fmt.Errorf("parse latest release URL %q: %w", releaseURL, err)
}
return baseURL.ResolveReference(parsed).String(), nil
}
}
return "", fmt.Errorf("latest release does not contain asset %q", assetName)
}
func DownloadReleaseAsset(ctx context.Context, client *http.Client, assetURL, targetPath string, auth Auth, source GitLabSource) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != nil {
return "", fmt.Errorf("build artifact download request: %w", err)
}
req.Header.Set("User-Agent", "mcp updater")
auth.apply(req)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("download release artifact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err := auth.maybeHint(resp.StatusCode, body, source); err != nil {
return "", fmt.Errorf("download release artifact: %w", err)
}
return "", fmt.Errorf(
"download release artifact: unexpected status %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
}
existingInfo, err := os.Stat(targetPath)
if err != nil {
return "", fmt.Errorf("stat executable %q: %w", targetPath, err)
}
tempFile, err := os.CreateTemp(filepath.Dir(targetPath), filepath.Base(targetPath)+".download-*")
if err != nil {
return "", fmt.Errorf("create temporary file: %w", err)
}
tempPath := tempFile.Name()
cleanup := func(copyErr error) (string, error) {
tempFile.Close()
os.Remove(tempPath)
return "", copyErr
}
if _, err := io.Copy(tempFile, resp.Body); err != nil {
return cleanup(fmt.Errorf("write downloaded artifact: %w", err))
}
if err := tempFile.Chmod(existingInfo.Mode().Perm()); err != nil {
return cleanup(fmt.Errorf("set executable mode on downloaded artifact: %w", err))
}
if err := tempFile.Close(); err != nil {
os.Remove(tempPath)
return "", fmt.Errorf("close downloaded artifact: %w", err)
}
return tempPath, nil
}
func ReplaceExecutable(downloadPath, targetPath string) error {
if runtime.GOOS == "windows" {
return errors.New("self-update is not supported on windows")
}
if err := os.Rename(downloadPath, targetPath); err != nil {
return fmt.Errorf("replace executable %q: %w", targetPath, err)
}
return nil
}
func normalizeSource(source GitLabSource) GitLabSource {
if source.TokenHeader == "" {
source.TokenHeader = "PRIVATE-TOKEN"
}
source.BaseURL = strings.TrimRight(strings.TrimSpace(source.BaseURL), "/")
source.ProjectPath = strings.TrimSpace(source.ProjectPath)
return source
}
func isCurrentRelease(currentVersion, latestTag string) bool {
current := strings.TrimSpace(currentVersion)
latest := strings.TrimSpace(latestTag)
if latest == "" {
return false
}
if current == "" || current == "dev" {
return false
}
return current == latest
}
func (a Auth) apply(req *http.Request) {
if strings.TrimSpace(a.Header) == "" || strings.TrimSpace(a.Token) == "" {
return
}
req.Header.Set(a.Header, a.Token)
}
func (a Auth) maybeHint(statusCode int, body []byte, source GitLabSource) error {
source = normalizeSource(source)
if strings.TrimSpace(a.Token) != "" || len(source.TokenEnvNames) == 0 {
return nil
}
switch statusCode {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
default:
return nil
}
message := strings.ToLower(strings.TrimSpace(string(body)))
if !strings.Contains(message, "project not found") &&
!strings.Contains(message, "unauthorized") &&
!strings.Contains(message, "forbidden") {
return nil
}
if len(source.TokenEnvNames) == 1 {
return fmt.Errorf(
"GitLab release access requires authentication on %s; set %s and retry",
source.BaseURL,
source.TokenEnvNames[0],
)
}
return fmt.Errorf(
"GitLab release access requires authentication on %s; set %s (or %s) and retry",
source.BaseURL,
source.TokenEnvNames[0],
source.TokenEnvNames[1],
)
}