first commit
This commit is contained in:
163
cli/internal/update/check.go
Normal file
163
cli/internal/update/check.go
Normal 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"
|
||||
}
|
||||
11
cli/internal/update/replace.go
Normal file
11
cli/internal/update/replace.go
Normal 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)
|
||||
}
|
||||
29
cli/internal/update/replace_windows.go
Normal file
29
cli/internal/update/replace_windows.go
Normal 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
|
||||
}
|
||||
330
cli/internal/update/selfupdate.go
Normal file
330
cli/internal/update/selfupdate.go
Normal 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
|
||||
}
|
||||
111
cli/internal/update/selfupdate_test.go
Normal file
111
cli/internal/update/selfupdate_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user