Skip to content

Commit 2b999ca

Browse files
authored
Merge pull request #522 from bborn/task/2023-add-version-deprecation-warning-to-cli
Add version deprecation warning to CLI commands
2 parents df4f927 + 54abffa commit 2b999ca

File tree

3 files changed

+183
-3
lines changed

3 files changed

+183
-3
lines changed

cmd/task/main.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ var (
4040
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
4141
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
4242
boldStyle = lipgloss.NewStyle().Bold(true)
43+
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B"))
4344
)
4445

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

101+
// Version deprecation warning for CLI subcommands.
102+
// Skip for root (TUI has its own check), upgrade, daemon, mcp-server, and claude-hook.
103+
skipVersionCheck := map[string]bool{
104+
"ty": true, // root command (TUI)
105+
"upgrade": true,
106+
"daemon": true,
107+
"mcp-server": true,
108+
"claude-hook": true,
109+
}
110+
rootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) {
111+
if skipVersionCheck[cmd.Name()] {
112+
return
113+
}
114+
if release := github.CLIVersionCheck(version); release != nil {
115+
fmt.Fprintln(os.Stderr, "")
116+
fmt.Fprintln(os.Stderr, warnStyle.Render(
117+
fmt.Sprintf("Update available: %s → %s (run: ty upgrade)", version, release.Version),
118+
))
119+
}
120+
}
121+
100122
// Debug subcommand
101123
debugCmd := &cobra.Command{
102124
Use: "debug",

internal/github/version.go

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"encoding/json"
77
"fmt"
88
"net/http"
9+
"os"
10+
"path/filepath"
911
"strings"
1012
"time"
1113
)
@@ -23,10 +25,19 @@ type LatestRelease struct {
2325
}
2426

2527
const (
26-
releaseRepo = "bborn/taskyou"
27-
releaseTimeout = 5 * time.Second
28+
releaseRepo = "bborn/taskyou"
29+
releaseTimeout = 5 * time.Second
30+
versionCacheTTL = 24 * time.Hour
31+
cacheFileName = "version-check.json"
2832
)
2933

34+
// versionCache is the on-disk cache for the latest release check.
35+
type versionCache struct {
36+
Version string `json:"version"`
37+
URL string `json:"url"`
38+
CheckedAt time.Time `json:"checked_at"`
39+
}
40+
3041
// FetchLatestRelease queries the GitHub API for the latest release.
3142
// Returns nil if the request fails or no release exists.
3243
func FetchLatestRelease() *LatestRelease {
@@ -114,3 +125,70 @@ func parseVersion(v string) []int {
114125
}
115126
return result
116127
}
128+
129+
// cacheDir returns the directory used for caching version check results.
130+
// Defaults to ~/.local/share/task/ but respects WORKTREE_DB_PATH if set.
131+
func cacheDir() string {
132+
if p := os.Getenv("WORKTREE_DB_PATH"); p != "" {
133+
return filepath.Dir(p)
134+
}
135+
home, _ := os.UserHomeDir()
136+
return filepath.Join(home, ".local", "share", "task")
137+
}
138+
139+
// readCache loads the version check cache from disk. Returns nil if missing or unreadable.
140+
func readCache() *versionCache {
141+
data, err := os.ReadFile(filepath.Join(cacheDir(), cacheFileName))
142+
if err != nil {
143+
return nil
144+
}
145+
var c versionCache
146+
if err := json.Unmarshal(data, &c); err != nil {
147+
return nil
148+
}
149+
return &c
150+
}
151+
152+
// writeCache persists the version check result to disk.
153+
func writeCache(release *LatestRelease) {
154+
c := versionCache{
155+
Version: release.Version,
156+
URL: release.URL,
157+
CheckedAt: time.Now(),
158+
}
159+
data, err := json.Marshal(c)
160+
if err != nil {
161+
return
162+
}
163+
dir := cacheDir()
164+
_ = os.MkdirAll(dir, 0o755)
165+
_ = os.WriteFile(filepath.Join(dir, cacheFileName), data, 0o644)
166+
}
167+
168+
// CLIVersionCheck checks if a newer version is available, using a 24h on-disk cache.
169+
// Returns the latest release if an upgrade is available, nil otherwise.
170+
// Skips the check entirely for "dev" builds.
171+
func CLIVersionCheck(currentVersion string) *LatestRelease {
172+
if currentVersion == "" || currentVersion == "dev" {
173+
return nil
174+
}
175+
176+
// Try the cache first
177+
if c := readCache(); c != nil && time.Since(c.CheckedAt) < versionCacheTTL {
178+
release := &LatestRelease{Version: c.Version, URL: c.URL}
179+
if IsNewerVersion(currentVersion, release.Version) {
180+
return release
181+
}
182+
return nil
183+
}
184+
185+
// Cache miss or stale — fetch from GitHub
186+
release := FetchLatestRelease()
187+
if release != nil {
188+
writeCache(release)
189+
if IsNewerVersion(currentVersion, release.Version) {
190+
return release
191+
}
192+
}
193+
return nil
194+
}

internal/github/version_test.go

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
package github
22

3-
import "testing"
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
410

511
func TestIsNewerVersion(t *testing.T) {
612
tests := []struct {
@@ -62,3 +68,77 @@ func TestParseVersion(t *testing.T) {
6268
})
6369
}
6470
}
71+
72+
func TestCLIVersionCheck_DevSkipped(t *testing.T) {
73+
if release := CLIVersionCheck("dev"); release != nil {
74+
t.Error("expected nil for dev version")
75+
}
76+
if release := CLIVersionCheck(""); release != nil {
77+
t.Error("expected nil for empty version")
78+
}
79+
}
80+
81+
func TestCLIVersionCheck_CachedNewerVersion(t *testing.T) {
82+
// Set up a temp directory for the cache
83+
tmp := t.TempDir()
84+
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))
85+
86+
// Write a fresh cache entry with a "newer" version
87+
c := versionCache{
88+
Version: "v99.0.0",
89+
URL: "https://github.com/bborn/taskyou/releases/tag/v99.0.0",
90+
CheckedAt: time.Now(),
91+
}
92+
data, _ := json.Marshal(c)
93+
_ = os.WriteFile(filepath.Join(tmp, cacheFileName), data, 0o644)
94+
95+
release := CLIVersionCheck("v1.0.0")
96+
if release == nil {
97+
t.Fatal("expected non-nil release for cached newer version")
98+
}
99+
if release.Version != "v99.0.0" {
100+
t.Errorf("got version %q, want v99.0.0", release.Version)
101+
}
102+
}
103+
104+
func TestCLIVersionCheck_CachedSameVersion(t *testing.T) {
105+
tmp := t.TempDir()
106+
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))
107+
108+
c := versionCache{
109+
Version: "v1.0.0",
110+
URL: "https://github.com/bborn/taskyou/releases/tag/v1.0.0",
111+
CheckedAt: time.Now(),
112+
}
113+
data, _ := json.Marshal(c)
114+
_ = os.WriteFile(filepath.Join(tmp, cacheFileName), data, 0o644)
115+
116+
release := CLIVersionCheck("v1.0.0")
117+
if release != nil {
118+
t.Error("expected nil when cached version equals current")
119+
}
120+
}
121+
122+
func TestReadWriteCache(t *testing.T) {
123+
tmp := t.TempDir()
124+
t.Setenv("WORKTREE_DB_PATH", filepath.Join(tmp, "tasks.db"))
125+
126+
// No cache yet
127+
if c := readCache(); c != nil {
128+
t.Error("expected nil for missing cache")
129+
}
130+
131+
// Write cache
132+
writeCache(&LatestRelease{Version: "v2.0.0", URL: "https://example.com"})
133+
134+
c := readCache()
135+
if c == nil {
136+
t.Fatal("expected non-nil cache after write")
137+
}
138+
if c.Version != "v2.0.0" {
139+
t.Errorf("got version %q, want v2.0.0", c.Version)
140+
}
141+
if time.Since(c.CheckedAt) > time.Minute {
142+
t.Error("cache CheckedAt should be recent")
143+
}
144+
}

0 commit comments

Comments
 (0)