Skip to content

Commit 5d5e9d4

Browse files
authored
notify when a new CLI version is available mid-session and improved messages (#50)
1 parent 96a8f0f commit 5d5e9d4

13 files changed

Lines changed: 381 additions & 44 deletions

File tree

internal/selfupdate.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e
221221
fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion)
222222

223223
if notes := release.ReleaseNotes; notes != "" {
224-
formatted := formatReleaseNotes(notes, 15)
224+
formatted := FormatReleaseNotes(notes, 15)
225225
if formatted != "" {
226226
fmt.Println()
227227
fmt.Println("Release notes:")
@@ -362,8 +362,8 @@ func handleBranchInstall(ctx context.Context, branch string, method InstallMetho
362362
}
363363
}
364364

365-
// formatReleaseNotes prepares release notes for terminal display.
366-
func formatReleaseNotes(body string, maxLines int) string {
365+
// FormatReleaseNotes prepares release notes for terminal display.
366+
func FormatReleaseNotes(body string, maxLines int) string {
367367
if strings.TrimSpace(body) == "" {
368368
return ""
369369
}

internal/selfupdate_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,13 @@ func TestCanWriteBinary(t *testing.T) {
155155

156156
func TestFormatReleaseNotes(t *testing.T) {
157157
t.Run("empty body", func(t *testing.T) {
158-
assert.Equal(t, "", formatReleaseNotes("", 15))
159-
assert.Equal(t, "", formatReleaseNotes(" \n\n ", 15))
158+
assert.Equal(t, "", FormatReleaseNotes("", 15))
159+
assert.Equal(t, "", FormatReleaseNotes(" \n\n ", 15))
160160
})
161161

162162
t.Run("short body under limit", func(t *testing.T) {
163163
body := "- Fixed bug in exercise reset\n- Added module skipping"
164-
result := formatReleaseNotes(body, 15)
164+
result := FormatReleaseNotes(body, 15)
165165
assert.Contains(t, result, "Fixed bug in exercise reset")
166166
assert.Contains(t, result, "Added module skipping")
167167
assert.NotContains(t, result, "see full release notes")
@@ -173,37 +173,37 @@ func TestFormatReleaseNotes(t *testing.T) {
173173
lines = append(lines, "- Change number "+string(rune('A'+i)))
174174
}
175175
body := strings.Join(lines, "\n")
176-
result := formatReleaseNotes(body, 5)
176+
result := FormatReleaseNotes(body, 5)
177177
assert.Contains(t, result, "Change number A")
178178
assert.Contains(t, result, "see full release notes")
179179
assert.NotContains(t, result, "Change number F")
180180
})
181181

182182
t.Run("strips markdown headers", func(t *testing.T) {
183183
body := "## What's Changed\n- Bug fix"
184-
result := formatReleaseNotes(body, 15)
184+
result := FormatReleaseNotes(body, 15)
185185
assert.Contains(t, result, "What's Changed")
186186
assert.NotContains(t, result, "##")
187187
})
188188

189189
t.Run("strips bold markers", func(t *testing.T) {
190190
body := "**Important**: This is a breaking change"
191-
result := formatReleaseNotes(body, 15)
191+
result := FormatReleaseNotes(body, 15)
192192
assert.Contains(t, result, "Important")
193193
assert.NotContains(t, result, "**")
194194
})
195195

196196
t.Run("strips markdown links", func(t *testing.T) {
197197
body := "See [the docs](https://example.com) for details"
198-
result := formatReleaseNotes(body, 15)
198+
result := FormatReleaseNotes(body, 15)
199199
assert.Contains(t, result, "the docs")
200200
assert.NotContains(t, result, "https://example.com")
201201
assert.NotContains(t, result, "[")
202202
})
203203

204204
t.Run("trims leading and trailing blank lines", func(t *testing.T) {
205205
body := "\n\n- First line\n- Second line\n\n"
206-
result := formatReleaseNotes(body, 15)
206+
result := FormatReleaseNotes(body, 15)
207207
assert.Contains(t, result, "First line")
208208
assert.Contains(t, result, "Second line")
209209
})

internal/update.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,74 @@ func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool)
8989
}
9090
}
9191

92+
// CheckUpdateAvailable performs a silent update check suitable for background
93+
// goroutines that poll on a long timer. It never prints and never prompts.
94+
// Returns whether a newer release is known for currentVersion, along with the
95+
// new version string and its release notes (if any).
96+
//
97+
// Honors TDL_NO_UPDATE_CHECK, skips when currentVersion is empty or "dev",
98+
// fast-paths the cached state file, and only hits the GitHub API once the
99+
// updateCheckInterval has elapsed. Respects the on-disk dismissal window so
100+
// a user who already declined the same version at startup isn't re-notified
101+
// mid-session.
102+
//
103+
// forcePrompt mirrors the semantics of CheckForUpdate's forcePrompt: bypass
104+
// the interval gate, accept any release that differs from currentVersion
105+
// (not just newer), and bypass the dismissal window. Intended for the hidden
106+
// --force-update-prompt testing flag.
107+
func CheckUpdateAvailable(currentVersion string, forcePrompt bool) (available bool, newVersion string, releaseNotes string) {
108+
if os.Getenv("TDL_NO_UPDATE_CHECK") != "" {
109+
return false, "", ""
110+
}
111+
if currentVersion == "" {
112+
return false, "", ""
113+
}
114+
if !forcePrompt && currentVersion == "dev" {
115+
return false, "", ""
116+
}
117+
118+
updateInfo, _ := getUpdateInfo()
119+
120+
if forcePrompt || time.Since(updateInfo.LastChecked) >= updateCheckInterval {
121+
if release := getLatestRelease(); release != nil {
122+
isNewer := release.Version != "" && isNewerVersion(release.Version, currentVersion)
123+
isDifferent := release.Version != "" && release.Version != currentVersion
124+
if isNewer || (forcePrompt && isDifferent) {
125+
updateInfo.CurrentVersion = currentVersion
126+
updateInfo.AvailableVersion = release.Version
127+
updateInfo.UpdateAvailable = true
128+
updateInfo.ReleaseNotes = release.ReleaseNotes
129+
} else {
130+
updateInfo.CurrentVersion = currentVersion
131+
updateInfo.AvailableVersion = ""
132+
updateInfo.UpdateAvailable = false
133+
updateInfo.ReleaseNotes = ""
134+
updateInfo.DismissedVersion = ""
135+
updateInfo.DismissedAt = time.Time{}
136+
}
137+
updateInfo.LastChecked = time.Now()
138+
_ = storeUpdateInfo(updateInfo)
139+
}
140+
}
141+
142+
if !updateInfo.UpdateAvailable {
143+
return false, "", ""
144+
}
145+
146+
if !forcePrompt {
147+
if !isNewerVersion(updateInfo.AvailableVersion, currentVersion) {
148+
return false, "", ""
149+
}
150+
// Respect the existing dismissal window — don't nag a user who already
151+
// declined the same version at startup.
152+
if !shouldShowBlockingPrompt(updateInfo) {
153+
return false, "", ""
154+
}
155+
}
156+
157+
return true, updateInfo.AvailableVersion, updateInfo.ReleaseNotes
158+
}
159+
92160
func showUpdatePromptOrNotice(updateInfo UpdateInfo, currentVersion string, isUpdateCommand bool, forcePrompt bool) {
93161
// If user is running "tdl update", skip — they're already updating
94162
if isUpdateCommand {
@@ -129,7 +197,7 @@ func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) {
129197
_, _ = c.Printf("Some features may be missing or not work correctly.\n")
130198

131199
if updateInfo.ReleaseNotes != "" {
132-
formatted := formatReleaseNotes(updateInfo.ReleaseNotes, 15)
200+
formatted := FormatReleaseNotes(updateInfo.ReleaseNotes, 15)
133201
if formatted != "" {
134202
fmt.Println()
135203
fmt.Println("Release notes:")

tdl/main.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -448,15 +448,16 @@ func newHandlers(c *cli.Context) *trainings.Handlers {
448448
mcpPort := c.Int("mcp-port")
449449

450450
return trainings.NewHandlers(trainings.CliMetadata{
451-
Version: version,
452-
Commit: commit,
453-
Architecture: runtime.GOARCH,
454-
OS: runtime.GOOS,
455-
OSVersion: osVersion(),
456-
GoVersion: runtime.Version(),
457-
GitVersion: gitVersionString(),
458-
ExecutedCommand: cmd,
459-
Interactive: internal.IsStdinTerminal(),
451+
Version: version,
452+
Commit: commit,
453+
Architecture: runtime.GOARCH,
454+
OS: runtime.GOOS,
455+
OSVersion: osVersion(),
456+
GoVersion: runtime.Version(),
457+
GitVersion: gitVersionString(),
458+
ExecutedCommand: cmd,
459+
Interactive: internal.IsStdinTerminal(),
460+
ForceUpdatePrompt: c.Bool("force-update-prompt"),
460461
}, mcpPort)
461462
}
462463

trainings/git_helpers.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,15 @@ func gitDefaultConfig(cfg *config.TrainingConfig) {
2929
}
3030

3131
// stageInitialFiles stages the base set of files for an initial commit:
32-
// .tdl-training, .gitignore, and optionally go.work if a Go workspace exists.
32+
// .gitignore, and optionally go.work if a Go workspace exists.
3333
// Extra files (e.g. a cloned exercise directory) can be appended.
34+
//
35+
// .tdl-training is intentionally omitted — it's gitignored as local state
36+
// (see the gitignore template in init.go), so force-adding it here would
37+
// fail silently. User progress is tracked via per-exercise commits, not
38+
// via .tdl-training.
3439
func stageInitialFiles(gitOps *git.Ops, trainingRootDir string, extraFiles ...string) {
35-
files := []string{".tdl-training", ".gitignore"}
40+
files := []string{".gitignore"}
3641
if hasGoWorkspace(trainingRootDir) {
3742
files = append(files, "go.work")
3843
}

trainings/git_helpers_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,14 @@ func TestStageInitialFiles_BaseCase(t *testing.T) {
9595

9696
stageInitialFiles(gitOps, dir)
9797

98-
// Check that files are staged
9998
cmd := exec.Command("git", "diff", "--cached", "--name-only")
10099
cmd.Dir = dir
101100
out, err := cmd.CombinedOutput()
102101
require.NoError(t, err)
103-
assert.Contains(t, string(out), ".tdl-training")
104102
assert.Contains(t, string(out), ".gitignore")
103+
// .tdl-training is local state; it must not land in the initial commit
104+
// even when present on disk.
105+
assert.NotContains(t, string(out), ".tdl-training")
105106
}
106107

107108
func TestStageInitialFiles_WithGoWork(t *testing.T) {

trainings/handlers.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"crypto/x509"
77
"fmt"
88
"runtime"
9+
"sync"
910
"time"
1011

1112
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/retry"
@@ -40,6 +41,15 @@ type Handlers struct {
4041
stdinCh <-chan rune // centralized stdin channel; non-nil only during interactiveRun with MCP
4142

4243
pendingMCPResultCh chan<- mcppkg.MCPResult // deferred result for blocking MCP commands (e.g. next exercise)
44+
45+
// Fallback update-state for when MCP is disabled (loopState == nil).
46+
// When loopState is non-nil all update accessors route through it so
47+
// MCP tool handlers see the same values as the terminal prompt.
48+
updateMu sync.Mutex
49+
updateAvailable bool
50+
updateVersion string
51+
updateReleaseNotes string
52+
updateNoticeShownCLI bool
4353
}
4454

4555
type CliMetadata struct {
@@ -54,6 +64,11 @@ type CliMetadata struct {
5464

5565
ExecutedCommand string
5666
Interactive bool
67+
68+
// ForceUpdatePrompt mirrors the top-level hidden --force-update-prompt
69+
// flag so the in-loop background update check can also bypass the
70+
// interval/dismissal gates for testing.
71+
ForceUpdatePrompt bool
5772
}
5873

5974
func NewHandlers(cliVersion CliMetadata, mcpPort int) *Handlers {

trainings/init.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,8 @@ var gitignore = strings.Join(
408408
"# Exercise content is subject to Three Dots Labs' copyright.",
409409
"**/" + files.ExerciseFile,
410410
"",
411-
"# TDL exercise state (managed by CLI)",
411+
"# TDL local state (managed by CLI)",
412+
".tdl-training",
412413
".tdl-exercise",
413414
"",
414415
"# AI coding tool configs (managed by CLI)",

trainings/mcp/state.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,15 @@ type LoopState struct {
9999
lastError string
100100

101101
transitionContent string // human-readable context from last exercise transition (e.g. diff)
102+
103+
// Background CLI-update signals. The terminal ("CLI") and MCP surfaces
104+
// each render the one-shot notice independently, so their "shown" flags
105+
// are tracked separately.
106+
updateAvailable bool
107+
updateVersion string
108+
updateReleaseNotes string
109+
updateNoticeShownCLI bool
110+
updateNoticeShownMCP bool
102111
}
103112

104113
func NewLoopState() *LoopState {
@@ -228,3 +237,55 @@ func (s *LoopState) ClearTransitionContent() {
228237
defer s.mu.Unlock()
229238
s.transitionContent = ""
230239
}
240+
241+
// SetUpdateAvailable records that a newer CLI release is available.
242+
// Called from the background update-check goroutine.
243+
func (s *LoopState) SetUpdateAvailable(version, releaseNotes string) {
244+
s.mu.Lock()
245+
defer s.mu.Unlock()
246+
if s.updateVersion != version {
247+
// A different (newer) version than what was previously signaled —
248+
// reset the "shown" flags so both surfaces re-notify.
249+
s.updateNoticeShownCLI = false
250+
s.updateNoticeShownMCP = false
251+
}
252+
s.updateAvailable = true
253+
s.updateVersion = version
254+
s.updateReleaseNotes = releaseNotes
255+
}
256+
257+
// GetUpdateAvailable returns the current background-check result.
258+
func (s *LoopState) GetUpdateAvailable() (available bool, version, releaseNotes string) {
259+
s.mu.RLock()
260+
defer s.mu.RUnlock()
261+
return s.updateAvailable, s.updateVersion, s.updateReleaseNotes
262+
}
263+
264+
// ShouldShowUpdateNoticeCLI reports whether the terminal should still print
265+
// its one-shot "update available" line. Returns false once
266+
// MarkUpdateNoticeShownCLI has been called for the current version.
267+
func (s *LoopState) ShouldShowUpdateNoticeCLI() bool {
268+
s.mu.RLock()
269+
defer s.mu.RUnlock()
270+
return s.updateAvailable && !s.updateNoticeShownCLI
271+
}
272+
273+
func (s *LoopState) MarkUpdateNoticeShownCLI() {
274+
s.mu.Lock()
275+
defer s.mu.Unlock()
276+
s.updateNoticeShownCLI = true
277+
}
278+
279+
// ShouldShowUpdateNoticeMCP is the MCP-side counterpart — gates the one-shot
280+
// note appended to tool-result messages.
281+
func (s *LoopState) ShouldShowUpdateNoticeMCP() bool {
282+
s.mu.RLock()
283+
defer s.mu.RUnlock()
284+
return s.updateAvailable && !s.updateNoticeShownMCP
285+
}
286+
287+
func (s *LoopState) MarkUpdateNoticeShownMCP() {
288+
s.mu.Lock()
289+
defer s.mu.Unlock()
290+
s.updateNoticeShownMCP = true
291+
}

0 commit comments

Comments
 (0)