// Package version handles version information and update checks.
package version
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"github.com/preloop/preloop/cli/internal/config"
)
// Build-time variables (set via ldflags).
var (
// Version is the current CLI version.
Version = "dev"
// Commit is the git commit hash.
Commit = "unknown"
// BuildDate is the build timestamp.
BuildDate = "unknown"
)
const (
// VersionCheckURL is the endpoint to check for new versions.
VersionCheckURL = "https://preloop.ai/api/v1/version"
// LastCheckFile is the filename for storing last check timestamp.
LastCheckFile = "last_version_check"
// CheckInterval is the minimum time between version checks.
CheckInterval = 24 * time.Hour
)
// VersionInfo represents the response from the version check endpoint.
type VersionInfo struct {
LatestVersion string `json:"latest_version"`
MinVersion string `json:"min_version"`
DownloadURL string `json:"download_url"`
ReleaseNotes string `json:"release_notes"`
}
// CheckForUpdate checks for a new version if a day has passed since the last check.
func CheckForUpdate() error {
if !shouldCheck() {
return nil
}
info, err := fetchVersionInfo()
if err != nil {
return err
}
// Update last check time
if err := updateLastCheckTime(); err != nil {
// Non-fatal, just log in verbose mode
_ = err
}
// Compare versions
if info.LatestVersion != "" && info.LatestVersion != Version && Version != "dev" {
displayUpdatePrompt(info)
}
return nil
}
// shouldCheck returns true if enough time has passed since the last check.
func shouldCheck() bool {
lastCheckPath, err := getLastCheckPath()
if err != nil {
return true // Check if we can't determine
}
data, err := os.ReadFile(lastCheckPath)
if err != nil {
return true // Check if file doesn't exist
}
lastCheck, err := time.Parse(time.RFC3339, string(data))
if err != nil {
return true // Check if parse fails
}
return time.Since(lastCheck) > CheckInterval
}
// getLastCheckPath returns the path to the last check timestamp file.
func getLastCheckPath() (string, error) {
configDir, err := config.GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, LastCheckFile), nil
}
// updateLastCheckTime writes the current time to the last check file.
func updateLastCheckTime() error {
lastCheckPath, err := getLastCheckPath()
if err != nil {
return err
}
// Ensure config directory exists
if err := os.MkdirAll(filepath.Dir(lastCheckPath), 0700); err != nil {
return err
}
return os.WriteFile(lastCheckPath, []byte(time.Now().Format(time.RFC3339)), 0600)
}
// fetchVersionInfo fetches the latest version information from the server.
func fetchVersionInfo() (*VersionInfo, error) {
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(VersionCheckURL)
if err != nil {
return nil, fmt.Errorf("failed to check for updates: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("version check returned status %d", resp.StatusCode)
}
var info VersionInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("failed to parse version info: %w", err)
}
return &info, nil
}
// displayUpdatePrompt shows a message about the available update.
func displayUpdatePrompt(info *VersionInfo) {
fmt.Println()
fmt.Println("╭─────────────────────────────────────────────────────────╮")
fmt.Printf("│ A new version of preloop is available: %s → %s │\n", Version, info.LatestVersion)
fmt.Println("│ │")
if info.DownloadURL != "" {
fmt.Printf("│ Download: %-45s │\n", info.DownloadURL)
} else {
fmt.Println("│ Run 'preloop update' to upgrade │")
}
fmt.Println("╰─────────────────────────────────────────────────────────╯")
fmt.Println()
}
// ForceCheck forces a version check regardless of the last check time.
func ForceCheck() (*VersionInfo, error) {
info, err := fetchVersionInfo()
if err != nil {
return nil, err
}
if err := updateLastCheckTime(); err != nil {
// Non-fatal
_ = err
}
return info, nil
}