first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
package update
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/maximhq/bifrost/cli/internal/config"
)
const (
baseURL = "https://downloads.getmaxim.ai"
versionURL = baseURL + "/bifrost-cli/latest/version.txt"
checkInterval = 24 * time.Hour
requestTimeout = 3 * time.Second
)
// CheckResult holds the outcome of a version check.
type CheckResult struct {
CurrentVersion string
LatestVersion string
UpdateAvailable bool
CheckedAt int64
}
// CheckInBackground starts a goroutine that checks for a newer CLI version
// and returns the result on the returned channel. The channel receives nil
// if no check was performed or if any error occurred (silent fail).
func CheckInBackground(currentVersion, statePath string) <-chan *CheckResult {
ch := make(chan *CheckResult, 1)
go func() {
defer func() {
if r := recover(); r != nil {
ch <- nil
}
}()
result := doCheck(currentVersion, statePath)
ch <- result
}()
return ch
}
func doCheck(currentVersion, statePath string) *CheckResult {
if currentVersion == "dev" || strings.TrimSpace(currentVersion) == "" {
return nil
}
if envSet("BIFROST_NO_UPDATE_CHECK") {
return nil
}
// Try cached version from state
state, err := config.LoadState(statePath)
if err == nil && state.LastKnownVersion != "" {
if time.Since(time.Unix(state.LastVersionCheck, 0)) < checkInterval {
return compareVersions(currentVersion, state.LastKnownVersion)
}
}
// Fetch latest version
latest, err := fetchLatestVersion()
if err != nil {
return nil
}
return compareVersions(currentVersion, latest)
}
func fetchLatestVersion() (string, error) {
client := &http.Client{Timeout: requestTimeout}
resp, err := client.Get(versionURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("version check: status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil {
return "", err
}
version := strings.TrimSpace(string(body))
if version == "" {
return "", fmt.Errorf("version check: empty response")
}
return version, nil
}
func compareVersions(current, latest string) *CheckResult {
result := &CheckResult{
CurrentVersion: current,
LatestVersion: latest,
UpdateAvailable: isNewer(latest, current),
CheckedAt: time.Now().Unix(),
}
return result
}
type parsedVersion struct {
nums [3]int
prerelease string // empty for stable releases
}
// isNewer returns true if a is newer than b. Both should be "vX.Y.Z" or
// "vX.Y.Z-prerelease" format. Per SemVer, a stable release is newer than a
// prerelease with the same major.minor.patch.
func isNewer(a, b string) bool {
av := parseVersion(a)
bv := parseVersion(b)
if av == nil || bv == nil {
return false
}
for i := 0; i < 3; i++ {
if av.nums[i] > bv.nums[i] {
return true
}
if av.nums[i] < bv.nums[i] {
return false
}
}
// Same major.minor.patch: stable > prerelease
if av.prerelease == "" && bv.prerelease != "" {
return true
}
return false
}
func parseVersion(v string) *parsedVersion {
v = strings.TrimPrefix(v, "v")
var pre string
if idx := strings.Index(v, "-"); idx != -1 {
pre = v[idx+1:]
v = v[:idx]
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return nil
}
var nums [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return &parsedVersion{nums: nums, prerelease: pre}
}
func envSet(key string) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return v == "1" || v == "true"
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package update
import "os"
// atomicReplace replaces oldPath with newPath using os.Rename.
// On Unix systems, this is atomic if both paths are on the same filesystem.
func atomicReplace(oldPath, newPath string) error {
return os.Rename(newPath, oldPath)
}

View File

@@ -0,0 +1,29 @@
//go:build windows
package update
import (
"fmt"
"os"
"path/filepath"
)
// atomicReplace replaces oldPath with newPath. On Windows, we cannot rename
// over a running executable directly, so we move the old binary aside first.
func atomicReplace(oldPath, newPath string) error {
backupPath := filepath.Join(filepath.Dir(oldPath), ".bifrost-old.exe")
os.Remove(backupPath) // clean up any previous backup
if err := os.Rename(oldPath, backupPath); err != nil {
return err
}
if err := os.Rename(newPath, oldPath); err != nil {
// Rollback — report if rollback also fails
if rbErr := os.Rename(backupPath, oldPath); rbErr != nil {
return fmt.Errorf("rename failed: %w; rollback also failed: %v (backup at %s)", err, rbErr, backupPath)
}
return err
}
os.Remove(backupPath)
return nil
}

View File

@@ -0,0 +1,330 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// RunSelfUpdate downloads and replaces the current binary with the latest version.
func RunSelfUpdate(currentVersion string) error {
fmt.Println("Checking latest bifrost version...")
latest, err := fetchLatestVersion()
if err != nil {
return fmt.Errorf("check latest version: %w", err)
}
if !isNewer(latest, currentVersion) {
fmt.Printf("Already up to date (%s).\n", currentVersion)
return nil
}
fmt.Printf("Updating bifrost from %s to %s...\n", currentVersion, latest)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
downloadURL := fmt.Sprintf("%s/bifrost-cli/%s/%s/%s/%s", baseURL, latest, runtime.GOOS, runtime.GOARCH, binaryName)
checksumURL := downloadURL + ".sha256"
fmt.Printf("Resolved update artifact: %s/%s\n", runtime.GOOS, runtime.GOARCH)
targetPath, err := resolveManagedBinaryTarget(binaryName)
if err != nil {
return fmt.Errorf("resolve managed binary path: %w", err)
}
fmt.Printf("Managed binary target: %s\n", targetPath)
// Download to the OS temp directory first so partial downloads never touch
// the managed install location.
tmpFile, err := os.CreateTemp("", ".bifrost-update-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
fmt.Printf("Temporary download path: %s\n", tmpPath)
// Download binary (use a generous timeout for large binaries)
downloadClient := &http.Client{Timeout: 5 * time.Minute}
fmt.Printf("Downloading update from %s\n", downloadURL)
resp, err := downloadClient.Get(downloadURL)
if err != nil {
tmpFile.Close()
return fmt.Errorf("download binary: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
tmpFile.Close()
return fmt.Errorf("download binary: status %d", resp.StatusCode)
}
hasher := sha256.New()
progress := newProgressLogger(resp.ContentLength)
defer progress.Finish()
writer := io.MultiWriter(tmpFile, hasher, progress)
if _, err := io.Copy(writer, resp.Body); err != nil {
tmpFile.Close()
return fmt.Errorf("write binary: %w", err)
}
progress.Finish()
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
return fmt.Errorf("sync binary: %w", err)
}
tmpFile.Close()
actualHash := hex.EncodeToString(hasher.Sum(nil))
// Verify checksum (mandatory — refuse to install unverified binaries)
fmt.Printf("Fetching checksum from %s\n", checksumURL)
expectedHash, err := fetchChecksum(checksumURL)
if err != nil {
return fmt.Errorf("checksum verification failed: %w", err)
}
if actualHash != expectedHash {
return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actualHash)
}
fmt.Println("Checksum verified.")
// Preserve permissions from old binary
fmt.Println("Preserving executable permissions...")
info, err := os.Stat(targetPath)
if err != nil {
return fmt.Errorf("stat target binary: %w", err)
}
stagePath, err := stageUpdateBinary(tmpPath, targetPath, info.Mode())
if err != nil {
return fmt.Errorf("stage binary: %w", err)
}
defer os.Remove(stagePath)
if err := os.Chmod(stagePath, info.Mode()); err != nil {
return fmt.Errorf("set permissions: %w", err)
}
// Atomic replace: rename new over old
fmt.Printf("Replacing binary at %s\n", targetPath)
if err := atomicReplace(targetPath, stagePath); err != nil {
return fmt.Errorf("replace binary: %w", err)
}
fmt.Printf("Updated bifrost from %s to %s. Please restart.\n", currentVersion, latest)
return nil
}
type progressLogger struct {
total int64
written int64
lastReport time.Time
finished bool
mu sync.Mutex
}
func newProgressLogger(total int64) *progressLogger {
return &progressLogger{
total: total,
lastReport: time.Now(),
}
}
func (p *progressLogger) Write(data []byte) (int, error) {
n := len(data)
p.mu.Lock()
defer p.mu.Unlock()
p.written += int64(n)
now := time.Now()
if now.Sub(p.lastReport) >= time.Second {
fmt.Println(p.statusLine())
p.lastReport = now
}
return n, nil
}
func (p *progressLogger) Finish() {
p.mu.Lock()
defer p.mu.Unlock()
if p.finished {
return
}
p.finished = true
fmt.Println(p.statusLine())
}
func (p *progressLogger) statusLine() string {
if p.total > 0 {
pct := float64(p.written) * 100 / float64(p.total)
return fmt.Sprintf("Download progress: %.1f%% (%s/%s)", pct, formatBytes(p.written), formatBytes(p.total))
}
return fmt.Sprintf("Download progress: %s", formatBytes(p.written))
}
func formatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for value := n / unit; value >= unit; value /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
func resolveManagedBinaryTarget(binaryName string) (string, error) {
execPath, err := os.Executable()
if err == nil {
if resolved, resolveErr := filepath.EvalSymlinks(execPath); resolveErr == nil {
execPath = resolved
}
if isWrapperManagedBinaryPath(execPath, binaryName) {
return execPath, nil
}
// If the running binary has the expected name, update it in place
// even if it's not under a wrapper-managed path.
if filepath.Base(execPath) == binaryName {
return execPath, nil
}
}
return bifrostCLIManagedBinaryPath(binaryName)
}
func bifrostCLIManagedBinaryPath(binaryName string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".bifrost", "bin", binaryName), nil
}
func isWrapperManagedBinaryPath(path, binaryName string) bool {
if path == "" {
return false
}
cleanPath := filepath.Clean(path)
homePath, err := bifrostCLIManagedBinaryPath(binaryName)
if err == nil && cleanPath == filepath.Clean(homePath) {
return true
}
cacheRoot, err := wrapperCacheRoot()
if err != nil {
return false
}
cachePrefix := filepath.Clean(filepath.Join(cacheRoot, "bifrost")) + string(os.PathSeparator)
if !strings.HasPrefix(cleanPath, cachePrefix) {
return false
}
base := filepath.Base(cleanPath)
if base == binaryName {
return true
}
return strings.HasPrefix(base, binaryName+"-")
}
func wrapperCacheRoot() (string, error) {
switch runtime.GOOS {
case "linux":
if xdg := strings.TrimSpace(os.Getenv("XDG_CACHE_HOME")); xdg != "" {
return xdg, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".cache"), nil
case "darwin":
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Caches"), nil
case "windows":
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
return localAppData, nil
}
userProfile := strings.TrimSpace(os.Getenv("USERPROFILE"))
if userProfile == "" {
return "", fmt.Errorf("userprofile is not set")
}
return filepath.Join(userProfile, "AppData", "Local"), nil
default:
return "", fmt.Errorf("unsupported platform %s", runtime.GOOS)
}
}
func stageUpdateBinary(downloadPath, targetPath string, mode os.FileMode) (string, error) {
stageFile, err := os.CreateTemp(filepath.Dir(targetPath), ".bifrost-stage-*")
if err != nil {
return "", err
}
stagePath := stageFile.Name()
src, err := os.Open(downloadPath)
if err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
defer src.Close()
if _, err := io.Copy(stageFile, src); err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
if err := stageFile.Sync(); err != nil {
stageFile.Close()
os.Remove(stagePath)
return "", err
}
if err := stageFile.Close(); err != nil {
os.Remove(stagePath)
return "", err
}
if err := os.Chmod(stagePath, mode); err != nil {
os.Remove(stagePath)
return "", err
}
return stagePath, nil
}
func fetchChecksum(url string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 256))
if err != nil {
return "", err
}
hash := strings.TrimSpace(strings.Split(string(body), " ")[0])
if hash == "" {
return "", fmt.Errorf("empty checksum")
}
return hash, nil
}

View File

@@ -0,0 +1,111 @@
package update
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func setTestHome(t *testing.T, home string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
return
}
t.Setenv("HOME", home)
}
func TestResolveManagedBinaryTargetFallsBackToCLIInstallLocation(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
got, err := bifrostCLIManagedBinaryPath(binaryName)
if err != nil {
t.Fatalf("bifrostCLIManagedBinaryPath() error = %v", err)
}
want := filepath.Join(home, ".bifrost", "bin", binaryName)
if got != want {
t.Fatalf("bifrostCLIManagedBinaryPath() = %q, want %q", got, want)
}
}
func TestIsWrapperManagedBinaryPathMatchesCLIInstallLocation(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
binaryName := "bifrost"
if runtime.GOOS == "windows" {
binaryName = "bifrost.exe"
}
path := filepath.Join(home, ".bifrost", "bin", binaryName)
if !isWrapperManagedBinaryPath(path, binaryName) {
t.Fatalf("expected %q to be recognized as wrapper-managed", path)
}
}
func TestIsWrapperManagedBinaryPathMatchesCacheInstallLocation(t *testing.T) {
if runtime.GOOS == "windows" {
localAppData := t.TempDir()
t.Setenv("LOCALAPPDATA", localAppData)
path := filepath.Join(localAppData, "bifrost", "v1.2.3", "bin", "bifrost.exe-0")
if !isWrapperManagedBinaryPath(path, "bifrost.exe") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
return
}
home := t.TempDir()
setTestHome(t, home)
if runtime.GOOS == "linux" {
xdg := filepath.Join(home, ".custom-cache")
t.Setenv("XDG_CACHE_HOME", xdg)
path := filepath.Join(xdg, "bifrost", "v1.2.3", "bin", "bifrost-0")
if !isWrapperManagedBinaryPath(path, "bifrost") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
return
}
path := filepath.Join(home, "Library", "Caches", "bifrost", "v1.2.3", "bin", "bifrost-0")
if !isWrapperManagedBinaryPath(path, "bifrost") {
t.Fatalf("expected %q to be recognized as wrapper-managed cache path", path)
}
}
func TestStageUpdateBinaryCopiesIntoTargetDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
targetDir := filepath.Join(root, ".bifrost", "bin")
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("mkdir target dir: %v", err)
}
downloadPath := filepath.Join(t.TempDir(), "downloaded-bifrost")
if err := os.WriteFile(downloadPath, []byte("new-binary"), 0o600); err != nil {
t.Fatalf("write download: %v", err)
}
targetPath := filepath.Join(targetDir, "bifrost")
stagePath, err := stageUpdateBinary(downloadPath, targetPath, 0o755)
if err != nil {
t.Fatalf("stageUpdateBinary() error = %v", err)
}
if filepath.Dir(stagePath) != targetDir {
t.Fatalf("stage path dir = %q, want %q", filepath.Dir(stagePath), targetDir)
}
if got, err := os.ReadFile(stagePath); err != nil {
t.Fatalf("read staged file: %v", err)
} else if string(got) != "new-binary" {
t.Fatalf("staged file contents = %q, want %q", string(got), "new-binary")
}
}