Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var (
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
boldStyle = lipgloss.NewStyle().Bold(true)
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B"))
)

// getSessionID returns a unique session identifier for this instance.
Expand Down Expand Up @@ -96,6 +97,27 @@ func main() {
rootCmd.PersistentFlags().BoolVar(&dangerous, "dangerous", false, "Run Claude with --dangerously-skip-permissions (for sandboxed environments)")
rootCmd.PersistentFlags().String("debug-state-file", "", "Path to write debug state JSON on update")

// Version deprecation warning for CLI subcommands.
// Skip for root (TUI has its own check), upgrade, daemon, mcp-server, and claude-hook.
skipVersionCheck := map[string]bool{
"ty": true, // root command (TUI)
"upgrade": true,
"daemon": true,
"mcp-server": true,
"claude-hook": true,
}
rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) {
if skipVersionCheck[cmd.Name()] {
return
}
if release := github.CLIVersionCheck(version); release != nil {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, warnStyle.Render(
fmt.Sprintf("Update available: %s → %s (run: ty upgrade)", version, release.Version),
))
}
}

// Debug subcommand
debugCmd := &cobra.Command{
Use: "debug",
Expand Down
82 changes: 80 additions & 2 deletions internal/github/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
Expand All @@ -23,10 +25,19 @@ type LatestRelease struct {
}

const (
releaseRepo = "bborn/taskyou"
releaseTimeout = 5 * time.Second
releaseRepo = "bborn/taskyou"
releaseTimeout = 5 * time.Second
versionCacheTTL = 24 * time.Hour
cacheFileName = "version-check.json"
)

// versionCache is the on-disk cache for the latest release check.
type versionCache struct {
Version string `json:"version"`
URL string `json:"url"`
CheckedAt time.Time `json:"checked_at"`
}

// FetchLatestRelease queries the GitHub API for the latest release.
// Returns nil if the request fails or no release exists.
func FetchLatestRelease() *LatestRelease {
Expand Down Expand Up @@ -114,3 +125,70 @@ func parseVersion(v string) []int {
}
return result
}

// cacheDir returns the directory used for caching version check results.
// Defaults to ~/.local/share/task/ but respects WORKTREE_DB_PATH if set.
func cacheDir() string {
if p := os.Getenv("WORKTREE_DB_PATH"); p != "" {
return filepath.Dir(p)
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "task")
}

// readCache loads the version check cache from disk. Returns nil if missing or unreadable.
func readCache() *versionCache {
data, err := os.ReadFile(filepath.Join(cacheDir(), cacheFileName))
if err != nil {
return nil
}
var c versionCache
if err := json.Unmarshal(data, &c); err != nil {
return nil
}
return &c
}

// writeCache persists the version check result to disk.
func writeCache(release *LatestRelease) {
c := versionCache{
Version: release.Version,
URL: release.URL,
CheckedAt: time.Now(),
}
data, err := json.Marshal(c)
if err != nil {
return
}
dir := cacheDir()
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, cacheFileName), data, 0o644)
}

// CLIVersionCheck checks if a newer version is available, using a 24h on-disk cache.
// Returns the latest release if an upgrade is available, nil otherwise.
// Skips the check entirely for "dev" builds.
func CLIVersionCheck(currentVersion string) *LatestRelease {
if currentVersion == "" || currentVersion == "dev" {
return nil
}

// Try the cache first
if c := readCache(); c != nil && time.Since(c.CheckedAt) < versionCacheTTL {
release := &LatestRelease{Version: c.Version, URL: c.URL}
if IsNewerVersion(currentVersion, release.Version) {
return release
}
return nil
}

// Cache miss or stale — fetch from GitHub
release := FetchLatestRelease()
if release != nil {
writeCache(release)
if IsNewerVersion(currentVersion, release.Version) {
return release
}
}
return nil
}
82 changes: 81 additions & 1 deletion internal/github/version_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package github

import "testing"
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)

func TestIsNewerVersion(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -62,3 +68,77 @@ func TestParseVersion(t *testing.T) {
})
}
}

func TestCLIVersionCheck_DevSkipped(t *testing.T) {
if release := CLIVersionCheck("dev"); release != nil {
t.Error("expected nil for dev version")
}
if release := CLIVersionCheck(""); release != nil {
t.Error("expected nil for empty version")
}
}

func TestCLIVersionCheck_CachedNewerVersion(t *testing.T) {
// Set up a temp directory for the cache
tmp := t.TempDir()
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))

// Write a fresh cache entry with a "newer" version
c := versionCache{
Version: "v99.0.0",
URL: "https://github.com/bborn/taskyou/releases/tag/v99.0.0",
CheckedAt: time.Now(),
}
data, _ := json.Marshal(c)
_ = os.WriteFile(filepath.Join(tmp, cacheFileName), data, 0o644)

release := CLIVersionCheck("v1.0.0")
if release == nil {
t.Fatal("expected non-nil release for cached newer version")
}
if release.Version != "v99.0.0" {
t.Errorf("got version %q, want v99.0.0", release.Version)
}
}

func TestCLIVersionCheck_CachedSameVersion(t *testing.T) {
tmp := t.TempDir()
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))

c := versionCache{
Version: "v1.0.0",
URL: "https://github.com/bborn/taskyou/releases/tag/v1.0.0",
CheckedAt: time.Now(),
}
data, _ := json.Marshal(c)
_ = os.WriteFile(filepath.Join(tmp, cacheFileName), data, 0o644)

release := CLIVersionCheck("v1.0.0")
if release != nil {
t.Error("expected nil when cached version equals current")
}
}

func TestReadWriteCache(t *testing.T) {
tmp := t.TempDir()
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))

// No cache yet
if c := readCache(); c != nil {
t.Error("expected nil for missing cache")
}

// Write cache
writeCache(&LatestRelease{Version: "v2.0.0", URL: "https://example.com"})

c := readCache()
if c == nil {
t.Fatal("expected non-nil cache after write")
}
if c.Version != "v2.0.0" {
t.Errorf("got version %q, want v2.0.0", c.Version)
}
if time.Since(c.CheckedAt) > time.Minute {
t.Error("cache CheckedAt should be recent")
}
}
Loading