diff --git a/cmd/task/main.go b/cmd/task/main.go index b94985f0..448ec3eb 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -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. @@ -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", diff --git a/internal/github/version.go b/internal/github/version.go index d063c4f4..761722a9 100644 --- a/internal/github/version.go +++ b/internal/github/version.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "path/filepath" "strings" "time" ) @@ -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 { @@ -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 +} diff --git a/internal/github/version_test.go b/internal/github/version_test.go index 0e3fa262..33a8bef6 100644 --- a/internal/github/version_test.go +++ b/internal/github/version_test.go @@ -1,6 +1,12 @@ package github -import "testing" +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) func TestIsNewerVersion(t *testing.T) { tests := []struct { @@ -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") + } +}