Files
Transmute/cli/internal/update/update.go
T
noah 4d5e73cfd1 fix: cream background on every style, warm highlight on cursor row
Every Lip Gloss style now has Background(ScreenBg) so ANSI sequences
carry the cream color inherently. Previously only PadLine added bg
to padding spaces, but inner escape codes reset the background.

All spacing between styled segments uses bg()/wbg() helpers.
Cursor row elements use Background(Warm) for highlight.
2026-03-09 23:18:27 +01:00

261 lines
5.7 KiB
Go

package update
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
)
const (
// CurrentVersion is the embedded build version. Updated at release time.
CurrentVersion = "0.1.2"
repoOwner = "noauf"
repoName = "Transmute"
apiURL = "https://api.github.com/repos/" + repoOwner + "/" + repoName + "/releases/latest"
)
// ghRelease is the subset of the GitHub release JSON we care about.
type ghRelease struct {
TagName string `json:"tag_name"`
Assets []ghAsset `json:"assets"`
}
type ghAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// Check queries GitHub for the latest release and reports whether an update
// is available, and if so, which version.
func Check() (available bool, latestVersion string, err error) {
rel, err := fetchLatest()
if err != nil {
return false, "", err
}
latest := strings.TrimPrefix(rel.TagName, "v")
if latest == CurrentVersion {
return false, latest, nil
}
return true, latest, nil
}
// Run performs a self-update: download the latest release binary and replace
// the current executable.
func Run(progress func(string)) error {
progress("Checking for updates...")
rel, err := fetchLatest()
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}
latest := strings.TrimPrefix(rel.TagName, "v")
if latest == CurrentVersion {
progress(fmt.Sprintf("Already up to date (v%s)", CurrentVersion))
return nil
}
progress(fmt.Sprintf("Update available: v%s -> v%s", CurrentVersion, latest))
// Find the matching asset for this OS/arch
assetName := buildAssetName()
var downloadURL string
for _, a := range rel.Assets {
if a.Name == assetName {
downloadURL = a.BrowserDownloadURL
break
}
}
if downloadURL == "" {
return fmt.Errorf("no release binary found for %s/%s (expected %s)", runtime.GOOS, runtime.GOARCH, assetName)
}
progress(fmt.Sprintf("Downloading %s...", assetName))
// Download to a temp file
tmpFile, err := downloadAsset(downloadURL)
if err != nil {
return fmt.Errorf("download failed: %w", err)
}
defer os.Remove(tmpFile)
// Extract binary from tarball (or use directly if not a tarball)
var binaryPath string
if strings.HasSuffix(assetName, ".tar.gz") {
binaryPath, err = extractTarGz(tmpFile)
if err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
defer os.Remove(binaryPath)
} else {
binaryPath = tmpFile
}
// Replace the current executable
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot determine executable path: %w", err)
}
progress("Installing update...")
if err := replaceExecutable(exePath, binaryPath); err != nil {
return fmt.Errorf("failed to replace executable: %w", err)
}
progress(fmt.Sprintf("Updated to v%s", latest))
return nil
}
func fetchLatest() (*ghRelease, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "transmute-cli/"+CurrentVersion)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var rel ghRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, err
}
return &rel, nil
}
// buildAssetName returns the expected asset filename for the current platform.
// Convention: transmute-<os>-<arch>.tar.gz (or .zip for Windows)
func buildAssetName() string {
goos := runtime.GOOS
arch := runtime.GOARCH
// Normalize arch names
switch arch {
case "amd64":
arch = "x86_64"
case "arm64":
arch = "arm64"
}
if goos == "windows" {
return fmt.Sprintf("transmute-%s-%s.zip", goos, arch)
}
return fmt.Sprintf("transmute-%s-%s.tar.gz", goos, arch)
}
func downloadAsset(url string) (string, error) {
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
tmp, err := os.CreateTemp("", "transmute-update-*")
if err != nil {
return "", err
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return "", err
}
tmp.Close()
return tmp.Name(), nil
}
func extractTarGz(archivePath string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// Look for the transmute binary
name := hdr.Name
if strings.HasSuffix(name, "/transmute") || name == "transmute" {
tmp, err := os.CreateTemp("", "transmute-bin-*")
if err != nil {
return "", err
}
if _, err := io.Copy(tmp, tr); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return "", err
}
tmp.Close()
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return "", err
}
return tmp.Name(), nil
}
}
return "", fmt.Errorf("transmute binary not found in archive")
}
func replaceExecutable(target, replacement string) error {
// Read the new binary
data, err := os.ReadFile(replacement)
if err != nil {
return err
}
// Get current executable's permissions
info, err := os.Stat(target)
if err != nil {
return err
}
// Write new binary to a temp file next to the target
tmpPath := target + ".new"
if err := os.WriteFile(tmpPath, data, info.Mode()); err != nil {
return err
}
// Atomic rename
if err := os.Rename(tmpPath, target); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}