Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

859 lines
19 KiB
Go

package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"golang.org/x/mod/semver"
)
const (
// githubLatestReleaseURL is the HTML endpoint that 302-redirects to
// /releases/tag/<tag>. Unlike api.github.com it is not rate-limited
// at 60 req/hr per IP for unauthenticated callers.
githubLatestReleaseURL = "https://github.com/kenn-io/agentsview/releases/latest"
githubReleaseDownloadBase = "https://github.com/kenn-io/agentsview/releases/download"
updateUserAgent = "agentsview-update"
cacheFileName = "update_check.json"
cacheDuration = 1 * time.Hour
devCacheDuration = 15 * time.Minute
)
// UpdateInfo contains information about an available update.
type UpdateInfo struct {
CurrentVersion string
LatestVersion string
DownloadURL string
AssetName string
Size int64
Checksum string
IsDevBuild bool
// cacheOnly is set when the info came from cache and lacks
// download metadata. The caller must re-fetch for installs.
cacheOnly bool
}
// NeedsRefetch returns true when the info came from cache
// and lacks the download URL/checksum needed for an install.
func (u *UpdateInfo) NeedsRefetch() bool {
return u.cacheOnly
}
type cachedCheck struct {
CheckedAt time.Time `json:"checked_at"`
Version string `json:"version"`
}
// CheckForUpdate checks if a newer version is available.
// Uses a 1-hour cache to avoid hitting the GitHub API often.
func CheckForUpdate(
currentVersion string,
forceCheck bool,
cacheDir string,
) (*UpdateInfo, error) {
cleanVersion := strings.TrimPrefix(currentVersion, "v")
isDevBuild := IsDevBuildVersion(cleanVersion)
if !forceCheck {
if info, done := checkCache(
currentVersion, cleanVersion, isDevBuild, cacheDir,
); done {
return info, nil
}
}
tag, err := resolveLatestTag(githubLatestReleaseURL)
if err != nil {
return nil, fmt.Errorf("check for updates: %w", err)
}
saveCache(tag, cacheDir)
latestVersion := strings.TrimPrefix(tag, "v")
if !isDevBuild && !isNewer(latestVersion, cleanVersion) {
return nil, nil
}
ext := ".tar.gz"
if runtime.GOOS == "windows" {
ext = ".zip"
}
assetName := fmt.Sprintf(
"agentsview_%s_%s_%s%s",
latestVersion, runtime.GOOS, runtime.GOARCH, ext,
)
downloadURL := fmt.Sprintf(
"%s/%s/%s", githubReleaseDownloadBase, tag, assetName,
)
checksumsURL := fmt.Sprintf(
"%s/%s/SHA256SUMS", githubReleaseDownloadBase, tag,
)
// HEAD the asset to confirm it exists for this platform. The previous
// API-based code returned "no release asset for OS/ARCH" up front; now
// that we construct the URL ourselves, we have to verify it resolves.
size, err := fetchContentLength(downloadURL)
if err != nil {
return nil, fmt.Errorf(
"no release asset for %s/%s: %w",
runtime.GOOS, runtime.GOARCH, err,
)
}
checksum, _ := fetchChecksumFromFile(checksumsURL, assetName)
return &UpdateInfo{
CurrentVersion: currentVersion,
LatestVersion: tag,
DownloadURL: downloadURL,
AssetName: assetName,
Size: size,
Checksum: checksum,
IsDevBuild: isDevBuild,
}, nil
}
// PerformUpdate downloads and installs the update.
func PerformUpdate(
info *UpdateInfo,
progressFn func(downloaded, total int64),
) error {
if info.Checksum == "" {
return fmt.Errorf(
"no checksum for %s - refusing unverified binary",
info.AssetName,
)
}
fmt.Printf("Downloading %s...\n", info.AssetName)
tempDir, err := os.MkdirTemp("", "agentsview-update-*")
if err != nil {
return fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tempDir)
archivePath := filepath.Join(tempDir, info.AssetName)
downloadChecksum, err := downloadFile(
info.DownloadURL, archivePath, info.Size, progressFn,
)
if err != nil {
return fmt.Errorf("download: %w", err)
}
if progressFn != nil {
fmt.Println()
}
fmt.Println("Verifying and installing...")
if err := installFromArchive(
archivePath, info.Checksum, downloadChecksum,
); err != nil {
return err
}
fmt.Println("Update complete.")
return nil
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func installFromArchive(
archivePath, expectedChecksum, precomputedChecksum string,
) error {
currentExe, err := os.Executable()
if err != nil {
return fmt.Errorf("find current executable: %w", err)
}
currentExe, err = filepath.EvalSymlinks(currentExe)
if err != nil {
return fmt.Errorf("resolve symlinks: %w", err)
}
binDir := filepath.Dir(currentExe)
binaryName := "agentsview"
if runtime.GOOS == "windows" {
binaryName = "agentsview.exe"
}
dstPath := filepath.Join(binDir, binaryName)
return installFromArchiveTo(
archivePath, expectedChecksum, dstPath,
precomputedChecksum,
)
}
func installFromArchiveTo(
archivePath, expectedChecksum, dstPath string,
precomputedChecksum string,
) error {
if expectedChecksum == "" {
return fmt.Errorf(
"empty checksum - refusing unverified binary",
)
}
checksum := precomputedChecksum
if checksum == "" {
var err error
checksum, err = hashFile(archivePath)
if err != nil {
return fmt.Errorf("hash archive: %w", err)
}
}
if !strings.EqualFold(checksum, expectedChecksum) {
return fmt.Errorf(
"checksum mismatch: expected %s, got %s",
expectedChecksum, checksum,
)
}
extractDir, err := os.MkdirTemp("", "agentsview-extract-*")
if err != nil {
return fmt.Errorf("create extract dir: %w", err)
}
defer os.RemoveAll(extractDir)
if strings.HasSuffix(archivePath, ".zip") {
if err := extractZip(archivePath, extractDir); err != nil {
return fmt.Errorf("extract: %w", err)
}
} else {
if err := extractTarGz(archivePath, extractDir); err != nil {
return fmt.Errorf("extract: %w", err)
}
}
binaryName := "agentsview"
if runtime.GOOS == "windows" {
binaryName = "agentsview.exe"
}
srcPath := filepath.Join(extractDir, binaryName)
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
return fmt.Errorf(
"binary %s not found in archive", binaryName,
)
}
return installBinaryTo(srcPath, dstPath)
}
// installBinaryTo replaces the binary at dstPath with the one
// at srcPath. The new binary is staged in a sibling tmp file
// with the executable mode bit set, then renamed into place.
//
// On Unix os.Rename atomically replaces dstPath in a single
// syscall, so concurrent readers always see one of the two
// binaries — never a missing or partial file. On Windows the
// existing binary must be moved aside first because os.Rename
// cannot replace a running executable; this leaves dstPath
// briefly missing between the two renames.
func installBinaryTo(srcPath, dstPath string) error {
backupPath := dstPath + ".old"
tmpPath := dstPath + ".new"
// Clean up leftovers from a prior failed update so they
// don't interfere with the renames below.
os.Remove(backupPath)
os.Remove(tmpPath)
installed := false
defer func() {
if !installed {
os.Remove(tmpPath)
}
}()
// Stage the new binary at tmpPath with executable mode set
// BEFORE touching the live binary at dstPath.
if err := copyFile(srcPath, tmpPath); err != nil {
return fmt.Errorf("install: %w", err)
}
if err := os.Chmod(tmpPath, 0o755); err != nil {
return fmt.Errorf("chmod: %w", err)
}
movedAside := false
if runtime.GOOS == "windows" {
aside, err := movePreviousAside(dstPath, backupPath)
if err != nil {
return err
}
movedAside = aside
}
if err := os.Rename(tmpPath, dstPath); err != nil {
if movedAside {
if rbErr := os.Rename(backupPath, dstPath); rbErr != nil {
return fmt.Errorf(
"install: %w (rollback also failed: %v)",
err, rbErr,
)
}
}
return fmt.Errorf("install: %w", err)
}
installed = true
os.Remove(backupPath)
return nil
}
// movePreviousAside renames an existing dstPath to backupPath.
// Used on Windows where os.Rename cannot replace a running
// executable. Returns true if dstPath was moved.
func movePreviousAside(dstPath, backupPath string) (bool, error) {
if _, err := os.Stat(dstPath); err != nil {
return false, nil
}
if err := os.Rename(dstPath, backupPath); err != nil {
return false, fmt.Errorf("backup: %w", err)
}
return true, nil
}
// resolveLatestTag follows the /releases/latest 302 redirect to
// /releases/tag/<tag> and returns the tag. Using the HTML endpoint
// avoids api.github.com's 60-req/hr unauthenticated rate limit.
func resolveLatestTag(url string) (string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", updateUserAgent)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
return "", fmt.Errorf(
"expected redirect from %s, got %s", url, resp.Status,
)
}
loc, err := resp.Location()
if err != nil {
return "", fmt.Errorf("read Location header: %w", err)
}
const marker = "/releases/tag/"
idx := strings.Index(loc.Path, marker)
if idx < 0 {
return "", fmt.Errorf(
"unexpected redirect target %q", loc.String(),
)
}
tag := loc.Path[idx+len(marker):]
if tag == "" {
return "", fmt.Errorf(
"empty tag in redirect target %q", loc.String(),
)
}
return tag, nil
}
// fetchContentLength does a HEAD request and returns the Content-Length
// of the eventual asset (following redirects to the S3 backend).
// Returns 0 if the size can't be determined; callers degrade gracefully.
func fetchContentLength(url string) (int64, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("HEAD", url, nil)
if err != nil {
return 0, err
}
req.Header.Set("User-Agent", updateUserAgent)
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf(
"HEAD %s returned %s", url, resp.Status,
)
}
if resp.ContentLength < 0 {
return 0, nil
}
return resp.ContentLength, nil
}
func downloadFile(
url, dest string,
totalSize int64,
progressFn func(downloaded, total int64),
) (string, error) {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: %s", resp.Status)
}
out, err := os.Create(dest)
if err != nil {
return "", err
}
defer out.Close()
hasher := sha256.New()
writer := io.MultiWriter(out, hasher)
var downloaded int64
buf := make([]byte, 32*1024)
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
_, writeErr := writer.Write(buf[:n])
if writeErr != nil {
return "", writeErr
}
downloaded += int64(n)
if progressFn != nil {
progressFn(downloaded, totalSize)
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return "", readErr
}
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
func extractTarGz(archivePath, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
absDestDir, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("resolve dest dir: %w", err)
}
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gzr, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, headerErr := tr.Next()
if headerErr == io.EOF {
break
}
if headerErr != nil {
return headerErr
}
target, targetErr := sanitizePath(absDestDir, header.Name)
if targetErr != nil {
return fmt.Errorf(
"invalid tar entry %q: %w",
header.Name, targetErr,
)
}
if header.Typeflag == tar.TypeSymlink ||
header.Typeflag == tar.TypeLink {
continue
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(
filepath.Dir(target), 0o755,
); err != nil {
return err
}
outFile, createErr := os.Create(target)
if createErr != nil {
return createErr
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
if err := os.Chmod(
target, os.FileMode(header.Mode),
); err != nil {
return err
}
}
}
return nil
}
// sanitizePath validates a path to prevent directory traversal.
func sanitizePath(destDir, name string) (string, error) {
if strings.HasPrefix(name, "/") {
return "", fmt.Errorf("absolute path not allowed")
}
cleanName := filepath.Clean(name)
if filepath.IsAbs(cleanName) {
return "", fmt.Errorf("absolute path not allowed")
}
if strings.HasPrefix(cleanName, "..") ||
strings.Contains(
cleanName, string(filepath.Separator)+"..",
) {
return "", fmt.Errorf("path traversal not allowed")
}
target := filepath.Join(destDir, cleanName)
absTarget, err := filepath.Abs(target)
if err != nil {
return "", err
}
absDestDir, err := filepath.Abs(destDir)
if err != nil {
return "", err
}
if !strings.HasPrefix(
absTarget, absDestDir+string(filepath.Separator),
) && absTarget != absDestDir {
return "", fmt.Errorf("path escapes destination directory")
}
return target, nil
}
func extractZip(archivePath, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
absDestDir, err := filepath.Abs(destDir)
if err != nil {
return fmt.Errorf("resolve dest dir: %w", err)
}
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
target, targetErr := sanitizePath(absDestDir, f.Name)
if targetErr != nil {
return fmt.Errorf(
"invalid zip entry %q: %w",
f.Name, targetErr,
)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(
filepath.Dir(target), 0o755,
); err != nil {
return err
}
rc, openErr := f.Open()
if openErr != nil {
return openErr
}
outFile, createErr := os.Create(target)
if createErr != nil {
rc.Close()
return createErr
}
_, copyErr := io.Copy(outFile, rc)
closeErr := outFile.Close()
rc.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
func fetchChecksumFromFile(
url, assetName string,
) (string, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url) //nolint:gosec
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf(
"failed to fetch checksums: %s", resp.Status,
)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return extractChecksum(string(body), assetName), nil
}
func extractChecksum(releaseBody, assetName string) string {
lines := strings.Split(releaseBody, "\n")
re := regexp.MustCompile(`(?i)[a-f0-9]{64}`)
for _, line := range lines {
line = strings.TrimSpace(line)
fields := strings.Fields(line)
if len(fields) >= 2 {
fname := strings.TrimPrefix(fields[1], "*")
if fname == assetName {
if match := re.FindString(fields[0]); match != "" {
return strings.ToLower(match)
}
}
}
}
return ""
}
func loadCache(cacheDir string) (*cachedCheck, error) {
cachePath := filepath.Join(cacheDir, cacheFileName)
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, err
}
var cached cachedCheck
if err := json.Unmarshal(data, &cached); err != nil {
return nil, err
}
return &cached, nil
}
func checkCache(
currentVersion, cleanVersion string,
isDevBuild bool,
cacheDir string,
) (*UpdateInfo, bool) {
cached, err := loadCache(cacheDir)
if err != nil {
return nil, false
}
cacheWindow := cacheDuration
if isDevBuild {
cacheWindow = devCacheDuration
}
if time.Since(cached.CheckedAt) >= cacheWindow {
return nil, false
}
latestVersion := strings.TrimPrefix(cached.Version, "v")
if isDevBuild {
// Cache only records the version, not full asset metadata.
// Return a cacheOnly UpdateInfo so the caller can display
// version info for --check, but re-fetches with full
// download metadata when an install is needed.
return &UpdateInfo{
CurrentVersion: currentVersion,
LatestVersion: cached.Version,
IsDevBuild: true,
cacheOnly: true,
}, true
}
if !isNewer(latestVersion, cleanVersion) {
return nil, true
}
return nil, false
}
func saveCache(version, cacheDir string) {
cached := cachedCheck{
CheckedAt: time.Now(),
Version: version,
}
data, err := json.Marshal(cached)
if err != nil {
return
}
cachePath := filepath.Join(cacheDir, cacheFileName)
_ = os.MkdirAll(filepath.Dir(cachePath), 0o755)
_ = os.WriteFile(cachePath, data, 0o600)
}
func extractBaseSemver(v string) string {
v = strings.TrimPrefix(v, "v")
if len(v) == 0 || v[0] < '0' || v[0] > '9' {
return ""
}
if !strings.Contains(v, ".") {
return ""
}
if idx := strings.Index(v, "-"); idx > 0 {
v = v[:idx]
}
return v
}
var gitDescribePattern = regexp.MustCompile(
`-\d+-g[0-9a-f]+(-dirty)?$`,
)
// IsDevBuildVersion returns true if the version is a dev build.
func IsDevBuildVersion(v string) bool {
v = strings.TrimPrefix(v, "v")
if extractBaseSemver(v) == "" {
return true
}
return gitDescribePattern.MatchString(v)
}
// IsNewer reports whether v1 is a semver release newer than v2. Dev builds
// and non-semver strings are not considered newer.
func IsNewer(v1, v2 string) bool {
base1 := extractBaseSemver(v1)
base2 := extractBaseSemver(v2)
if base1 == "" || base2 == "" {
return false
}
sv1 := normalizeSemver(v1)
sv2 := normalizeSemver(v2)
return semver.Compare(sv1, sv2) > 0
}
func isNewer(v1, v2 string) bool {
return IsNewer(v1, v2)
}
var prereleaseNumericPattern = regexp.MustCompile(
`^([A-Za-z]+)(\d+)$`,
)
func normalizeSemver(v string) string {
v = strings.TrimPrefix(v, "v")
if gitDescribePattern.MatchString(v) {
v = gitDescribePattern.ReplaceAllString(v, "")
}
if idx := strings.Index(v, "-"); idx > 0 {
base := v[:idx]
prerelease := v[idx+1:]
prerelease = normalizePrereleaseIdentifiers(prerelease)
v = base + "-" + prerelease
}
return "v" + v
}
func normalizePrereleaseIdentifiers(prerelease string) string {
parts := strings.Split(prerelease, ".")
var result []string
for _, part := range parts {
matches := prereleaseNumericPattern.FindStringSubmatch(part)
if matches != nil {
letters, digits := matches[1], matches[2]
if len(digits) > 1 && digits[0] == '0' {
result = append(result, part)
} else {
result = append(result, letters, digits)
}
} else {
result = append(result, part)
}
}
return strings.Join(result, ".")
}
// FormatSize formats bytes as a human-readable string.
func FormatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf(
"%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp],
)
}