feat: add self-update command with periodic update hints

This commit is contained in:
Rahul Bansal 2026-02-21 11:09:26 +05:30
parent 9ba4c530aa
commit 408b3ea06f
5 changed files with 845 additions and 0 deletions

View file

@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/update"
) )
func agentCmd() { func agentCmd() {
@ -66,6 +67,12 @@ func agentCmd() {
} }
} }
// Check for updates in the background (non-blocking, at most once per 24h)
updateHint := make(chan string, 1)
go func() {
updateHint <- update.CheckHint(version)
}()
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
@ -109,6 +116,17 @@ func agentCmd() {
"skills_available": startupInfo["skills"].(map[string]any)["available"], "skills_available": startupInfo["skills"].(map[string]any)["available"],
}) })
// Print update hint if available (non-blocking select)
printUpdateHint := func() {
select {
case v := <-updateHint:
if v != "" {
fmt.Printf("\nA new version (%s) is available. Run 'picoclaw update' to upgrade.\n\n", v)
}
default:
}
}
if message != "" { if message != "" {
ctx := context.Background() ctx := context.Background()
spin := newSpinner("Thinking...") spin := newSpinner("Thinking...")
@ -120,7 +138,9 @@ func agentCmd() {
os.Exit(1) os.Exit(1)
} }
fmt.Printf("\n%s %s\n", logo, response) fmt.Printf("\n%s %s\n", logo, response)
printUpdateHint()
} else { } else {
printUpdateHint()
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
interactiveMode(agentLoop, sessionKey) interactiveMode(agentLoop, sessionKey)
} }

View file

@ -0,0 +1,82 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"github.com/sipeed/picoclaw/pkg/update"
)
func updateCmd() {
// Handle flags
checkOnly := false
for _, arg := range os.Args[2:] {
switch arg {
case "--help", "-h":
updateHelp()
return
case "--check", "-c":
checkOnly = true
}
}
fmt.Printf("%s Checking for updates...\n", logo)
release, err := update.CheckLatest()
if err != nil {
fmt.Printf("Error checking for updates: %v\n", err)
os.Exit(1)
}
latestVersion := release.TagName
if !update.IsNewer(version, latestVersion) {
fmt.Printf("Already up to date! (current: %s, latest: %s)\n", version, latestVersion)
return
}
fmt.Printf("New version available: %s (current: %s)\n", latestVersion, version)
fmt.Printf("Release: %s\n", release.HTMLURL)
if checkOnly {
return
}
assetURL, err := update.FindAssetURL(release)
if err != nil {
fmt.Printf("Error: %v\n", err)
fmt.Printf("You can download manually from: %s\n", release.HTMLURL)
os.Exit(1)
}
fmt.Printf("\nUpdate to %s? (y/n): ", latestVersion)
var confirm string
fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" {
fmt.Println("Update cancelled.")
return
}
if err := update.DownloadAndReplace(assetURL, os.Stdout); err != nil {
fmt.Printf("Update failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("\n%s Updated to %s!\n", logo, latestVersion)
}
func updateHelp() {
fmt.Println("Check for updates and self-update picoclaw")
fmt.Println()
fmt.Println("Usage: picoclaw update [flags]")
fmt.Println()
fmt.Println("Checks GitHub for the latest release and offers to update in-place.")
fmt.Println()
fmt.Println("Flags:")
fmt.Println(" -c, --check Check only, don't prompt to update")
fmt.Println(" -h, --help Show this help")
}

View file

@ -120,6 +120,8 @@ func main() {
sessionsCmd() sessionsCmd()
case "skills": case "skills":
skillsCmd() skillsCmd()
case "update":
updateCmd()
case "version", "--version", "-v": case "version", "--version", "-v":
printVersion() printVersion()
case "--help", "-h": case "--help", "-h":
@ -146,6 +148,7 @@ func printHelp() {
fmt.Println(" skills Manage skills (install, list, remove)") fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" sessions Manage sessions (list, show, delete, clear)") fmt.Println(" sessions Manage sessions (list, show, delete, clear)")
fmt.Println(" doctor Diagnose and fix common problems (--fix to auto-repair)") fmt.Println(" doctor Diagnose and fix common problems (--fix to auto-repair)")
fmt.Println(" update Check for updates and self-update")
fmt.Println(" version Show version information") fmt.Println(" version Show version information")
} }

442
pkg/update/update.go Normal file
View file

@ -0,0 +1,442 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
// GitHubRepo is the upstream repository for release checks
GitHubRepo = "sipeed/picoclaw"
// releaseAPIURL is the GitHub API endpoint for the latest release
releaseAPIURL = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest"
// checkInterval is how often the periodic hint checks for updates
checkInterval = 24 * time.Hour
// httpTimeout is the timeout for GitHub API and download requests
httpTimeout = 30 * time.Second
)
// httpClient is the HTTP client used for all requests. Tests can replace it.
var httpClient = &http.Client{Timeout: httpTimeout}
// ReleaseInfo holds information about a GitHub release
type ReleaseInfo struct {
TagName string `json:"tag_name"`
Assets []ReleaseAsset `json:"assets"`
HTMLURL string `json:"html_url"`
}
// ReleaseAsset holds information about a release asset
type ReleaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
}
// checkCache stores the last update check result
type checkCache struct {
LastCheck time.Time `json:"last_check"`
LatestVersion string `json:"latest_version"`
HTMLURL string `json:"html_url"`
}
// CheckLatest fetches the latest release info from GitHub
func CheckLatest() (*ReleaseInfo, error) {
req, err := http.NewRequest("GET", releaseAPIURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "picoclaw-updater")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching latest release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode)
}
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, fmt.Errorf("parsing release info: %w", err)
}
return &release, nil
}
// IsNewer returns true if the remote version is newer than the current version.
// Both versions should be semver strings, optionally prefixed with "v".
func IsNewer(current, remote string) bool {
curParts := parseSemver(current)
remParts := parseSemver(remote)
if curParts == nil || remParts == nil {
return false
}
for i := 0; i < 3; i++ {
if remParts[i] > curParts[i] {
return true
}
if remParts[i] < curParts[i] {
return false
}
}
return false
}
// parseSemver extracts [major, minor, patch] from a version string.
// Accepts "v1.2.3", "1.2.3", "v0.1.2-42-gabcdef", etc.
func parseSemver(v string) []int {
v = strings.TrimPrefix(v, "v")
// Strip anything after a hyphen (pre-release/build metadata)
if idx := strings.Index(v, "-"); idx != -1 {
v = v[:idx]
}
parts := strings.Split(v, ".")
if len(parts) != 3 {
return nil
}
nums := make([]int, 3)
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return nil
}
nums[i] = n
}
return nums
}
// AssetName returns the expected asset filename for the current platform.
// Asset naming convention: picoclaw_{OS}_{Arch}.tar.gz (or .zip for Windows)
func AssetName() string {
osName := normalizeOS(runtime.GOOS)
archName := normalizeArch(runtime.GOARCH)
ext := "tar.gz"
if runtime.GOOS == "windows" {
ext = "zip"
}
return fmt.Sprintf("picoclaw_%s_%s.%s", osName, archName, ext)
}
func normalizeOS(goos string) string {
switch goos {
case "darwin":
return "Darwin"
case "linux":
return "Linux"
case "windows":
return "Windows"
case "freebsd":
return "Freebsd"
default:
// Title case the first letter
return strings.ToUpper(goos[:1]) + goos[1:]
}
}
func normalizeArch(goarch string) string {
switch goarch {
case "amd64":
return "x86_64"
case "arm64":
return "arm64"
case "arm":
return "armv6"
case "riscv64":
return "riscv64"
case "mips64":
return "mips64"
case "s390x":
return "s390x"
default:
return goarch
}
}
// FindAssetURL finds the download URL for the current platform in a release
func FindAssetURL(release *ReleaseInfo) (string, error) {
want := AssetName()
for _, asset := range release.Assets {
if asset.Name == want {
return asset.BrowserDownloadURL, nil
}
}
return "", fmt.Errorf("no release asset found for %s", want)
}
// DownloadAndReplace downloads the release asset and replaces the current binary.
// It writes progress to the provided writer.
func DownloadAndReplace(downloadURL string, progress io.Writer) error {
// 1. Download the archive
fmt.Fprintf(progress, "Downloading %s...\n", filepath.Base(downloadURL))
resp, err := httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("downloading release: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
}
tmpDir, err := os.MkdirTemp("", "picoclaw-update-*")
if err != nil {
return fmt.Errorf("creating temp directory: %w", err)
}
defer os.RemoveAll(tmpDir)
archivePath := filepath.Join(tmpDir, filepath.Base(downloadURL))
archiveFile, err := os.Create(archivePath)
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
written, err := io.Copy(archiveFile, resp.Body)
archiveFile.Close()
if err != nil {
return fmt.Errorf("saving download: %w", err)
}
fmt.Fprintf(progress, "Downloaded %.1f MB\n", float64(written)/1024/1024)
// 2. Extract the binary
fmt.Fprintf(progress, "Extracting...\n")
binaryPath, err := extractBinary(archivePath, tmpDir)
if err != nil {
return fmt.Errorf("extracting archive: %w", err)
}
// 3. Replace the current binary
currentBinary, err := os.Executable()
if err != nil {
return fmt.Errorf("finding current binary: %w", err)
}
currentBinary, err = filepath.EvalSymlinks(currentBinary)
if err != nil {
return fmt.Errorf("resolving binary path: %w", err)
}
fmt.Fprintf(progress, "Replacing %s...\n", currentBinary)
if err := replaceBinary(binaryPath, currentBinary); err != nil {
return fmt.Errorf("replacing binary: %w", err)
}
// 4. Recreate pico symlink if it exists next to the binary
picoSymlink := filepath.Join(filepath.Dir(currentBinary), "pico")
if target, err := os.Readlink(picoSymlink); err == nil {
// Only recreate if it was pointing at a picoclaw binary
if strings.Contains(target, "picoclaw") {
os.Remove(picoSymlink)
os.Symlink(filepath.Base(currentBinary), picoSymlink)
}
}
return nil
}
// extractBinary extracts the picoclaw binary from a tar.gz or zip archive
func extractBinary(archivePath, destDir string) (string, error) {
if strings.HasSuffix(archivePath, ".zip") {
return extractFromZip(archivePath, destDir)
}
return extractFromTarGz(archivePath, destDir)
}
func extractFromTarGz(archivePath, destDir string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer f.Close()
gzr, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("opening gzip: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("reading tar: %w", err)
}
name := filepath.Base(header.Name)
if name == "picoclaw" || name == "picoclaw.exe" {
outPath := filepath.Join(destDir, name)
outFile, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return "", err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return "", err
}
outFile.Close()
return outPath, nil
}
}
return "", fmt.Errorf("picoclaw binary not found in archive")
}
func extractFromZip(archivePath, destDir string) (string, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return "", err
}
defer r.Close()
for _, f := range r.File {
name := filepath.Base(f.Name)
if name == "picoclaw" || name == "picoclaw.exe" {
rc, err := f.Open()
if err != nil {
return "", err
}
outPath := filepath.Join(destDir, name)
outFile, err := os.OpenFile(outPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
rc.Close()
return "", err
}
if _, err := io.Copy(outFile, rc); err != nil {
outFile.Close()
rc.Close()
return "", err
}
outFile.Close()
rc.Close()
return outPath, nil
}
}
return "", fmt.Errorf("picoclaw binary not found in archive")
}
// replaceBinary atomically replaces the old binary with the new one
func replaceBinary(newPath, oldPath string) error {
// Rename old binary to .bak
bakPath := oldPath + ".bak"
if err := os.Rename(oldPath, bakPath); err != nil {
return fmt.Errorf("backing up current binary: %w", err)
}
// Copy new binary into place (can't rename across filesystems)
src, err := os.Open(newPath)
if err != nil {
// Restore backup
os.Rename(bakPath, oldPath)
return fmt.Errorf("opening new binary: %w", err)
}
defer src.Close()
dst, err := os.OpenFile(oldPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
os.Rename(bakPath, oldPath)
return fmt.Errorf("writing new binary: %w", err)
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
os.Rename(bakPath, oldPath)
return fmt.Errorf("copying new binary: %w", err)
}
dst.Close()
// Remove backup
os.Remove(bakPath)
return nil
}
// --- Periodic hint support ---
// cacheFilePath returns the path to the update check cache file.
// Tests can override this variable.
var cacheFilePath = func() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "last_update_check.json")
}
// CheckHint checks if a newer version is available, using a cached result
// if the last check was within the check interval. Returns the latest version
// string if an update is available, or empty string if up-to-date or on error.
// This is designed to be called from a goroutine and never block.
func CheckHint(currentVersion string) string {
// 1. Try to read cache
cache, err := loadCache()
if err == nil && time.Since(cache.LastCheck) < checkInterval {
// Cache is fresh, use it
if IsNewer(currentVersion, cache.LatestVersion) {
return cache.LatestVersion
}
return ""
}
// 2. Cache is stale or missing, check GitHub
release, err := CheckLatest()
if err != nil {
return ""
}
// 3. Update cache
newCache := checkCache{
LastCheck: time.Now(),
LatestVersion: release.TagName,
HTMLURL: release.HTMLURL,
}
saveCache(&newCache)
if IsNewer(currentVersion, release.TagName) {
return release.TagName
}
return ""
}
func loadCache() (*checkCache, error) {
data, err := os.ReadFile(cacheFilePath())
if err != nil {
return nil, err
}
var cache checkCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
return &cache, nil
}
func saveCache(cache *checkCache) {
data, err := json.Marshal(cache)
if err != nil {
return
}
dir := filepath.Dir(cacheFilePath())
os.MkdirAll(dir, 0o755)
os.WriteFile(cacheFilePath(), data, 0o644)
}

298
pkg/update/update_test.go Normal file
View file

@ -0,0 +1,298 @@
package update
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestParseSemver(t *testing.T) {
tests := []struct {
name string
input string
want []int
}{
{name: "plain", input: "1.2.3", want: []int{1, 2, 3}},
{name: "v-prefix", input: "v0.1.2", want: []int{0, 1, 2}},
{name: "with-prerelease", input: "v0.1.2-42-gabcdef", want: []int{0, 1, 2}},
{name: "with-dirty", input: "v0.1.2-dirty", want: []int{0, 1, 2}},
{name: "invalid-empty", input: "", want: nil},
{name: "invalid-two-parts", input: "1.2", want: nil},
{name: "invalid-letters", input: "v1.x.3", want: nil},
{name: "dev", input: "dev", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseSemver(tt.input)
if tt.want == nil {
if got != nil {
t.Errorf("parseSemver(%q) = %v, want nil", tt.input, got)
}
return
}
if got == nil {
t.Fatalf("parseSemver(%q) = nil, want %v", tt.input, tt.want)
}
for i := 0; i < 3; i++ {
if got[i] != tt.want[i] {
t.Errorf("parseSemver(%q)[%d] = %d, want %d", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestIsNewer(t *testing.T) {
tests := []struct {
name string
current string
remote string
want bool
}{
{name: "newer-patch", current: "v0.1.2", remote: "v0.1.3", want: true},
{name: "newer-minor", current: "v0.1.2", remote: "v0.2.0", want: true},
{name: "newer-major", current: "v0.1.2", remote: "v1.0.0", want: true},
{name: "same", current: "v0.1.2", remote: "v0.1.2", want: false},
{name: "older", current: "v0.2.0", remote: "v0.1.9", want: false},
{name: "current-with-metadata", current: "v0.1.2-42-gabcdef", remote: "v0.1.3", want: true},
{name: "same-with-metadata", current: "v0.1.2-42-gabcdef", remote: "v0.1.2", want: false},
{name: "invalid-current", current: "dev", remote: "v0.1.2", want: false},
{name: "invalid-remote", current: "v0.1.2", remote: "dev", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsNewer(tt.current, tt.remote)
if got != tt.want {
t.Errorf("IsNewer(%q, %q) = %v, want %v", tt.current, tt.remote, got, tt.want)
}
})
}
}
func TestNormalizeOS(t *testing.T) {
tests := []struct {
input string
want string
}{
{"darwin", "Darwin"},
{"linux", "Linux"},
{"windows", "Windows"},
{"freebsd", "Freebsd"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeOS(tt.input)
if got != tt.want {
t.Errorf("normalizeOS(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeArch(t *testing.T) {
tests := []struct {
input string
want string
}{
{"amd64", "x86_64"},
{"arm64", "arm64"},
{"arm", "armv6"},
{"riscv64", "riscv64"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := normalizeArch(tt.input)
if got != tt.want {
t.Errorf("normalizeArch(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestCheckLatest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
release := ReleaseInfo{
TagName: "v1.0.0",
HTMLURL: "https://github.com/sipeed/picoclaw/releases/tag/v1.0.0",
Assets: []ReleaseAsset{
{Name: "picoclaw_Darwin_arm64.tar.gz", BrowserDownloadURL: "https://example.com/picoclaw_Darwin_arm64.tar.gz"},
},
}
json.NewEncoder(w).Encode(release)
}))
defer server.Close()
// Override the HTTP client to use test server
origClient := httpClient
httpClient = server.Client()
defer func() { httpClient = origClient }()
// Override releaseAPIURL by testing CheckLatest indirectly via FindAssetURL
// For direct testing, we test the JSON parsing
resp, err := httpClient.Get(server.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
var release ReleaseInfo
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
t.Fatalf("unexpected decode error: %v", err)
}
if release.TagName != "v1.0.0" {
t.Errorf("TagName = %q, want %q", release.TagName, "v1.0.0")
}
}
func TestFindAssetURL(t *testing.T) {
assetName := AssetName()
release := &ReleaseInfo{
Assets: []ReleaseAsset{
{Name: "picoclaw_Linux_x86_64.tar.gz", BrowserDownloadURL: "https://example.com/linux"},
{Name: assetName, BrowserDownloadURL: "https://example.com/match"},
{Name: "picoclaw_Windows_x86_64.zip", BrowserDownloadURL: "https://example.com/windows"},
},
}
url, err := FindAssetURL(release)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://example.com/match" {
t.Errorf("FindAssetURL() = %q, want %q", url, "https://example.com/match")
}
// Test missing asset
release.Assets = []ReleaseAsset{
{Name: "picoclaw_UnknownOS_unknownarch.tar.gz", BrowserDownloadURL: "https://example.com/nope"},
}
_, err = FindAssetURL(release)
if err == nil {
t.Error("expected error for missing asset, got nil")
}
}
func TestExtractFromTarGz(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "update-test-*")
if err != nil {
t.Fatalf("creating temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a test tar.gz with a picoclaw binary
archivePath := filepath.Join(tmpDir, "test.tar.gz")
binaryContent := []byte("#!/bin/sh\necho hello\n")
f, err := os.Create(archivePath)
if err != nil {
t.Fatalf("creating archive: %v", err)
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
// Add a non-binary file first
if err := tw.WriteHeader(&tar.Header{Name: "README.md", Size: 6, Mode: 0o644}); err != nil {
t.Fatalf("writing tar header: %v", err)
}
tw.Write([]byte("readme"))
// Add the binary
if err := tw.WriteHeader(&tar.Header{Name: "picoclaw", Size: int64(len(binaryContent)), Mode: 0o755}); err != nil {
t.Fatalf("writing tar header: %v", err)
}
tw.Write(binaryContent)
tw.Close()
gw.Close()
f.Close()
// Extract
destDir := filepath.Join(tmpDir, "extracted")
os.MkdirAll(destDir, 0o755)
binPath, err := extractFromTarGz(archivePath, destDir)
if err != nil {
t.Fatalf("extractFromTarGz: %v", err)
}
data, err := os.ReadFile(binPath)
if err != nil {
t.Fatalf("reading extracted binary: %v", err)
}
if string(data) != string(binaryContent) {
t.Errorf("extracted content = %q, want %q", string(data), string(binaryContent))
}
}
func TestReplaceBinary(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "replace-test-*")
if err != nil {
t.Fatalf("creating temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
oldPath := filepath.Join(tmpDir, "picoclaw")
newPath := filepath.Join(tmpDir, "picoclaw-new")
os.WriteFile(oldPath, []byte("old"), 0o755)
os.WriteFile(newPath, []byte("new"), 0o755)
if err := replaceBinary(newPath, oldPath); err != nil {
t.Fatalf("replaceBinary: %v", err)
}
data, err := os.ReadFile(oldPath)
if err != nil {
t.Fatalf("reading replaced binary: %v", err)
}
if string(data) != "new" {
t.Errorf("replaced binary content = %q, want %q", string(data), "new")
}
// Backup should be cleaned up
if _, err := os.Stat(oldPath + ".bak"); !os.IsNotExist(err) {
t.Error("backup file was not cleaned up")
}
}
func TestCacheRoundTrip(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "cache-test-*")
if err != nil {
t.Fatalf("creating temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Override home dir for cache path
origCacheFilePath := cacheFilePath
cacheFilePath = func() string {
return filepath.Join(tmpDir, "last_update_check.json")
}
defer func() { cacheFilePath = origCacheFilePath }()
cache := &checkCache{
LastCheck: time.Now().Truncate(time.Second),
LatestVersion: "v1.2.3",
HTMLURL: "https://example.com",
}
saveCache(cache)
loaded, err := loadCache()
if err != nil {
t.Fatalf("loadCache: %v", err)
}
if loaded.LatestVersion != cache.LatestVersion {
t.Errorf("LatestVersion = %q, want %q", loaded.LatestVersion, cache.LatestVersion)
}
if loaded.HTMLURL != cache.HTMLURL {
t.Errorf("HTMLURL = %q, want %q", loaded.HTMLURL, cache.HTMLURL)
}
}