From c57f460fefa21a7e56d678812c30c6b218e82e20 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 13:49:00 -0700 Subject: [PATCH 1/8] Add plugin version checking and auto-update awareness Detect outdated Claude Code plugin installations by comparing the installed plugin version against the CLI version. Show a warning in `basecamp doctor` and a one-time post-upgrade hint pointing users to enable auto-update in Claude Code. The setup wizard now recommends enabling auto-update after plugin installation. --- internal/cli/root.go | 6 ++ internal/commands/wizard_agents.go | 5 ++ internal/harness/claude.go | 100 ++++++++++++++++++++++++++- internal/harness/claude_test.go | 107 +++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 1 deletion(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 459e7690..53ae24a4 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ import ( "github.com/basecamp/basecamp-cli/internal/commands" "github.com/basecamp/basecamp-cli/internal/completion" "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/harness" "github.com/basecamp/basecamp-cli/internal/hostutil" "github.com/basecamp/basecamp-cli/internal/output" "github.com/basecamp/basecamp-cli/internal/tui" @@ -162,6 +163,11 @@ func NewRootCmd() *cobra.Command { if commands.RefreshSkillsIfVersionChanged() { if app == nil || !app.IsMachineOutput() { fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version) + + // One-time hint: if plugin is outdated after a CLI upgrade, nudge the user + if pv := harness.InstalledPluginVersion(); pv != "" && pv != version.Version && !version.IsDev() { + fmt.Fprintf(os.Stderr, "Basecamp plugin outdated (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint) + } } } return nil diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index bed7985b..2f1b58ba 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -131,6 +131,11 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Claude skill symlink failed: %s", err))) } + // Nudge: recommend enabling auto-update for the marketplace + fmt.Fprintln(w) + fmt.Fprintln(w, styles.Muted.Render(" Tip: Enable auto-update to stay current with new CLI releases:")) + fmt.Fprintln(w, styles.Muted.Render(" "+harness.AutoUpdateHint)) + return nil } diff --git a/internal/harness/claude.go b/internal/harness/claude.go index b7105d1f..594e2064 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -2,9 +2,12 @@ package harness import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" + + "github.com/basecamp/basecamp-cli/internal/version" ) func init() { @@ -14,11 +17,12 @@ func init() { Detect: DetectClaude, Checks: func() []*StatusCheck { checks := []*StatusCheck{CheckClaudePlugin()} - // Only check the skill link if ~/.claude exists (i.e. Claude is dir-detected) + // Only check the skill link and plugin version if ~/.claude exists home, err := os.UserHomeDir() if err == nil { if info, statErr := os.Stat(filepath.Join(home, ".claude")); statErr == nil && info.IsDir() { checks = append(checks, CheckClaudeSkillLink()) + checks = append(checks, CheckClaudePluginVersion()) } } return checks @@ -163,6 +167,100 @@ func CheckClaudeSkillLink() *StatusCheck { } } +// AutoUpdateHint is the user-facing instruction for enabling plugin auto-update. +const AutoUpdateHint = "In Claude Code: /plugins → Marketplaces → 37signals → Enable auto-update" + +// CheckClaudePluginVersion compares the installed plugin version against the +// running CLI version. Returns a warn check when they differ. +func CheckClaudePluginVersion() *StatusCheck { + installed := InstalledPluginVersion() + if installed == "" { + // Can't determine version — don't nag. + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: "Version not tracked", + } + } + + cliVersion := version.Version + if cliVersion == "dev" { + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Installed (%s, dev build)", installed), + } + } + + if installed == cliVersion { + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "pass", + Message: fmt.Sprintf("Up to date (%s)", installed), + } + } + + return &StatusCheck{ + Name: "Claude Code Plugin Version", + Status: "warn", + Message: fmt.Sprintf("Outdated (plugin %s, CLI %s)", installed, cliVersion), + Hint: AutoUpdateHint, + } +} + +// InstalledPluginVersion reads the installed plugin version from +// ~/.claude/plugins/installed_plugins.json. Returns "" if unreadable. +func InstalledPluginVersion() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(filepath.Clean(home), ".claude", "plugins", "installed_plugins.json")) //nolint:gosec // G304: trusted path + if err != nil { + return "" + } + return installedPluginVersion(data) +} + +// installedPluginVersion extracts the version of the basecamp plugin from +// the installed_plugins.json data. Handles v2 and array formats. +func installedPluginVersion(data []byte) string { + // v2 format: {"version": 2, "plugins": {"basecamp@37signals": [{"version": "1.0.0", ...}]}} + var pluginMap map[string]any + if err := json.Unmarshal(data, &pluginMap); err == nil { + if inner, ok := pluginMap["plugins"]; ok { + if innerMap, ok := inner.(map[string]any); ok { + for key, val := range innerMap { + if !matchesPluginKey(key) { + continue + } + if arr, ok := val.([]any); ok && len(arr) > 0 { + if obj, ok := arr[0].(map[string]any); ok { + if v, ok := obj["version"].(string); ok { + return v + } + } + } + } + } + } + } + + // Array format: [{"name": "basecamp@37signals", "version": "1.0.0", ...}] + var plugins []map[string]any + if err := json.Unmarshal(data, &plugins); err == nil { + for _, p := range plugins { + if matchesBasecamp(p) { + if v, ok := p["version"].(string); ok { + return v + } + } + } + } + + return "" +} + // pluginInstalled checks if "basecamp" appears as an installed plugin. // Handles multiple possible JSON schemas without panicking. func pluginInstalled(data []byte) bool { diff --git a/internal/harness/claude_test.go b/internal/harness/claude_test.go index 1d7371cf..464cad1c 100644 --- a/internal/harness/claude_test.go +++ b/internal/harness/claude_test.go @@ -7,6 +7,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" ) func TestPluginInstalled_ArrayFormat(t *testing.T) { @@ -223,3 +225,108 @@ func TestIsStalePluginKey(t *testing.T) { assert.False(t, isStalePluginKey("basecamp")) assert.False(t, isStalePluginKey("other@basecamp")) } + +func TestInstalledPluginVersion_V2(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"1.2.3"}]}}`) + assert.Equal(t, "1.2.3", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_V2_BareKey(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"basecamp":[{"scope":"user","version":"0.5.0"}]}}`) + assert.Equal(t, "0.5.0", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_V2_NotFound(t *testing.T) { + data := []byte(`{"version":2,"plugins":{"other@marketplace":[{"scope":"user","version":"1.0.0"}]}}`) + assert.Equal(t, "", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Array(t *testing.T) { + data := []byte(`[{"name":"basecamp@37signals","version":"2.0.0"}]`) + assert.Equal(t, "2.0.0", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Array_NotFound(t *testing.T) { + data := []byte(`[{"name":"other-plugin","version":"1.0.0"}]`) + assert.Equal(t, "", installedPluginVersion(data)) +} + +func TestInstalledPluginVersion_Empty(t *testing.T) { + assert.Equal(t, "", installedPluginVersion([]byte(`{}`))) + assert.Equal(t, "", installedPluginVersion([]byte(`[]`))) + assert.Equal(t, "", installedPluginVersion([]byte(`invalid`))) +} + +func TestCheckClaudePluginVersion_UpToDate(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "1.5.0" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"1.5.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "1.5.0") +} + +func TestCheckClaudePluginVersion_Outdated(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "2.0.0" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"0.1.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "warn", check.Status) + assert.Contains(t, check.Message, "0.1.0") + assert.Contains(t, check.Message, "2.0.0") + assert.Contains(t, check.Hint, "auto-update") +} + +func TestCheckClaudePluginVersion_DevBuild(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + origVersion := version.Version + version.Version = "dev" + defer func() { version.Version = origVersion }() + + pluginsDir := filepath.Join(home, ".claude", "plugins") + require.NoError(t, os.MkdirAll(pluginsDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(pluginsDir, "installed_plugins.json"), + []byte(`{"version":2,"plugins":{"basecamp@37signals":[{"scope":"user","version":"0.1.0"}]}}`), + 0o644, + )) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "dev build") +} + +func TestCheckClaudePluginVersion_NoFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + check := CheckClaudePluginVersion() + assert.Equal(t, "pass", check.Status) + assert.Contains(t, check.Message, "not tracked") +} From 0e825fa1f353f0f3157ebe3fcb5e3abadfc28614 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 15:22:43 -0700 Subject: [PATCH 2/8] Address review feedback: flat-map format, wording, conditional tip --- internal/cli/root.go | 2 +- internal/commands/wizard_agents.go | 10 ++++++---- internal/harness/claude.go | 26 ++++++++++++++++++++------ internal/harness/claude_test.go | 5 +++++ 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 53ae24a4..df6ea1ca 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -166,7 +166,7 @@ func NewRootCmd() *cobra.Command { // One-time hint: if plugin is outdated after a CLI upgrade, nudge the user if pv := harness.InstalledPluginVersion(); pv != "" && pv != version.Version && !version.IsDev() { - fmt.Fprintf(os.Stderr, "Basecamp plugin outdated (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint) + fmt.Fprintf(os.Stderr, "Basecamp plugin version mismatch (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint) } } } diff --git a/internal/commands/wizard_agents.go b/internal/commands/wizard_agents.go index 2f1b58ba..afa91514 100644 --- a/internal/commands/wizard_agents.go +++ b/internal/commands/wizard_agents.go @@ -131,10 +131,12 @@ func runClaudeSetup(cmd *cobra.Command, styles *tui.Styles) error { fmt.Fprintln(w, styles.Warning.Render(fmt.Sprintf(" Claude skill symlink failed: %s", err))) } - // Nudge: recommend enabling auto-update for the marketplace - fmt.Fprintln(w) - fmt.Fprintln(w, styles.Muted.Render(" Tip: Enable auto-update to stay current with new CLI releases:")) - fmt.Fprintln(w, styles.Muted.Render(" "+harness.AutoUpdateHint)) + // Nudge: recommend enabling auto-update (only when plugin is actually installed) + if harness.CheckClaudePlugin().Status == "pass" { + fmt.Fprintln(w) + fmt.Fprintln(w, styles.Muted.Render(" Tip: Enable auto-update to stay current with new CLI releases:")) + fmt.Fprintln(w, styles.Muted.Render(" "+harness.AutoUpdateHint)) + } return nil } diff --git a/internal/harness/claude.go b/internal/harness/claude.go index 594e2064..53e7797e 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -203,7 +203,7 @@ func CheckClaudePluginVersion() *StatusCheck { return &StatusCheck{ Name: "Claude Code Plugin Version", Status: "warn", - Message: fmt.Sprintf("Outdated (plugin %s, CLI %s)", installed, cliVersion), + Message: fmt.Sprintf("Mismatched (plugin %s, CLI %s)", installed, cliVersion), Hint: AutoUpdateHint, } } @@ -223,7 +223,7 @@ func InstalledPluginVersion() string { } // installedPluginVersion extracts the version of the basecamp plugin from -// the installed_plugins.json data. Handles v2 and array formats. +// the installed_plugins.json data. Handles v2, v1 flat map, and array formats. func installedPluginVersion(data []byte) string { // v2 format: {"version": 2, "plugins": {"basecamp@37signals": [{"version": "1.0.0", ...}]}} var pluginMap map[string]any @@ -234,16 +234,30 @@ func installedPluginVersion(data []byte) string { if !matchesPluginKey(key) { continue } - if arr, ok := val.([]any); ok && len(arr) > 0 { - if obj, ok := arr[0].(map[string]any); ok { - if v, ok := obj["version"].(string); ok { - return v + if arr, ok := val.([]any); ok { + for _, entry := range arr { + if obj, ok := entry.(map[string]any); ok { + if v, ok := obj["version"].(string); ok { + return v + } } } } } } } + + // v1 flat map: {"basecamp@37signals": {"version": "1.0.0"}} + for key, val := range pluginMap { + if !matchesPluginKey(key) { + continue + } + if obj, ok := val.(map[string]any); ok { + if v, ok := obj["version"].(string); ok { + return v + } + } + } } // Array format: [{"name": "basecamp@37signals", "version": "1.0.0", ...}] diff --git a/internal/harness/claude_test.go b/internal/harness/claude_test.go index 464cad1c..156c9613 100644 --- a/internal/harness/claude_test.go +++ b/internal/harness/claude_test.go @@ -251,6 +251,11 @@ func TestInstalledPluginVersion_Array_NotFound(t *testing.T) { assert.Equal(t, "", installedPluginVersion(data)) } +func TestInstalledPluginVersion_V1FlatMap(t *testing.T) { + data := []byte(`{"basecamp@37signals":{"version":"1.0.0"}}`) + assert.Equal(t, "1.0.0", installedPluginVersion(data)) +} + func TestInstalledPluginVersion_Empty(t *testing.T) { assert.Equal(t, "", installedPluginVersion([]byte(`{}`))) assert.Equal(t, "", installedPluginVersion([]byte(`[]`))) From b6963be5f3ce10158086f8046e76e520dd2a0c91 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:05:39 -0700 Subject: [PATCH 3/8] Move plugin version check to doctor-only The version mismatch check was in the generic agent Checks list, causing the setup wizard and other callers to treat a version difference as 'plugin not installed'. The version check now only appears in `basecamp doctor`. --- internal/commands/doctor.go | 12 ++++++++++++ internal/harness/claude.go | 3 +-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 915f7060..293a79ee 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -218,6 +218,18 @@ func runDoctorChecks(ctx context.Context, app *appctx.App, verbose bool) []Check } } + // 13. Claude plugin version (doctor-only, not part of generic agent checks + // which gate setup wizard behavior) + if harness.DetectClaude() { + pvc := harness.CheckClaudePluginVersion() + checks = append(checks, Check{ + Name: pvc.Name, + Status: pvc.Status, + Message: pvc.Message, + Hint: pvc.Hint, + }) + } + return checks } diff --git a/internal/harness/claude.go b/internal/harness/claude.go index 53e7797e..c7d57dc7 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -17,12 +17,11 @@ func init() { Detect: DetectClaude, Checks: func() []*StatusCheck { checks := []*StatusCheck{CheckClaudePlugin()} - // Only check the skill link and plugin version if ~/.claude exists + // Only check the skill link if ~/.claude exists (i.e. Claude is dir-detected) home, err := os.UserHomeDir() if err == nil { if info, statErr := os.Stat(filepath.Join(home, ".claude")); statErr == nil && info.IsDir() { checks = append(checks, CheckClaudeSkillLink()) - checks = append(checks, CheckClaudePluginVersion()) } } return checks From 3da51bc605fb350743ef32353f72c2f7299f50c4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:24:12 -0700 Subject: [PATCH 4/8] Guard empty home, fix comment wording --- internal/cli/root.go | 2 +- internal/harness/claude.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index df6ea1ca..04803191 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -164,7 +164,7 @@ func NewRootCmd() *cobra.Command { if app == nil || !app.IsMachineOutput() { fmt.Fprintf(os.Stderr, "Agent skill updated to match CLI %s\n", version.Version) - // One-time hint: if plugin is outdated after a CLI upgrade, nudge the user + // One-time hint: if plugin/CLI version mismatch after upgrade, nudge the user if pv := harness.InstalledPluginVersion(); pv != "" && pv != version.Version && !version.IsDev() { fmt.Fprintf(os.Stderr, "Basecamp plugin version mismatch (plugin %s, CLI %s) — %s\n", pv, version.Version, harness.AutoUpdateHint) } diff --git a/internal/harness/claude.go b/internal/harness/claude.go index c7d57dc7..c14b734d 100644 --- a/internal/harness/claude.go +++ b/internal/harness/claude.go @@ -211,7 +211,7 @@ func CheckClaudePluginVersion() *StatusCheck { // ~/.claude/plugins/installed_plugins.json. Returns "" if unreadable. func InstalledPluginVersion() string { home, err := os.UserHomeDir() - if err != nil { + if err != nil || home == "" { return "" } data, err := os.ReadFile(filepath.Join(filepath.Clean(home), ".claude", "plugins", "installed_plugins.json")) //nolint:gosec // G304: trusted path From 8250f6f92914d302bd755f1d773eef0d6a6b7766 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 13:51:24 -0700 Subject: [PATCH 5/8] Add ambient update notice for interactive TTY sessions Show a non-intrusive update notice on stderr when a newer CLI version is available. Uses a background goroutine with a 24-hour cache to avoid adding latency to commands. Respects BASECAMP_NO_UPDATE_CHECK=1 and suppresses output for non-interactive or machine consumers. --- internal/cli/root.go | 14 ++ internal/commands/update_notice.go | 118 +++++++++++++++ internal/commands/update_notice_test.go | 187 ++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 internal/commands/update_notice.go create mode 100644 internal/commands/update_notice_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 04803191..f3b245f3 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -27,6 +27,7 @@ import ( // NewRootCmd creates the root cobra command. func NewRootCmd() *cobra.Command { var flags appctx.GlobalFlags + var updateCheck *commands.UpdateCheck cmd := &cobra.Command{ Use: "basecamp", @@ -43,6 +44,9 @@ func NewRootCmd() *cobra.Command { return nil } + // Start background update check early so it runs during command execution + updateCheck = commands.StartUpdateCheck() + // Bare root (no subcommand, no args) will show help or // quickstart depending on mode. Tolerate config/profile // errors so bad config doesn't block help, while still @@ -170,6 +174,16 @@ func NewRootCmd() *cobra.Command { } } } + + // Ambient update notice — only for interactive TTY sessions + if updateCheck != nil { + if notice := updateCheck.Notice(); notice != "" { + if app != nil && app.IsInteractive() && !app.IsMachineOutput() { + fmt.Fprintln(os.Stderr, notice) + } + } + } + return nil } diff --git a/internal/commands/update_notice.go b/internal/commands/update_notice.go new file mode 100644 index 00000000..a4f3b6f9 --- /dev/null +++ b/internal/commands/update_notice.go @@ -0,0 +1,118 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/basecamp/basecamp-cli/internal/config" + "github.com/basecamp/basecamp-cli/internal/version" +) + +// checkInterval is how often we query GitHub for the latest version. +var checkInterval = 24 * time.Hour + +// UpdateCheck holds state for a non-blocking version check. +type UpdateCheck struct { + latest string + done chan struct{} +} + +// StartUpdateCheck begins a background version check if the cache is stale. +// Returns nil if the check should be skipped (dev build, opted out, etc.). +func StartUpdateCheck() *UpdateCheck { + if version.IsDev() { + return nil + } + if os.Getenv("BASECAMP_NO_UPDATE_CHECK") == "1" { + return nil + } + + uc := &UpdateCheck{done: make(chan struct{})} + cached := readUpdateCache() + + if cached != nil && time.Since(cached.CheckedAt) < checkInterval { + // Cache is fresh — use it directly, no goroutine needed + uc.latest = cached.LatestVersion + close(uc.done) + return uc + } + + // Cache is stale or missing — fetch in the background + go func() { + defer close(uc.done) + latest, err := versionChecker() + if err != nil || latest == "" { + return + } + uc.latest = latest + writeUpdateCache(latest) + }() + + return uc +} + +// Notice returns a formatted update notice, or "" if no update is available +// or the check hasn't completed. Never blocks. +func (uc *UpdateCheck) Notice() string { + if uc == nil { + return "" + } + + // Non-blocking check: if the goroutine hasn't finished, skip + select { + case <-uc.done: + default: + return "" + } + + if uc.latest == "" || uc.latest == version.Version { + return "" + } + + return fmt.Sprintf( + "Update available: %s → %s — Run \"basecamp upgrade\" to update", + version.Version, uc.latest, + ) +} + +// updateCache is the on-disk format for the version check result. +type updateCache struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +func updateCachePath() string { + return filepath.Join(config.GlobalConfigDir(), ".update-check") +} + +func readUpdateCache() *updateCache { + data, err := os.ReadFile(updateCachePath()) + if err != nil { + return nil + } + var c updateCache + if err := json.Unmarshal(data, &c); err != nil { + return nil + } + if c.LatestVersion == "" || c.CheckedAt.IsZero() { + return nil + } + return &c +} + +func writeUpdateCache(latestVersion string) { + c := updateCache{ + LatestVersion: latestVersion, + CheckedAt: time.Now().UTC(), + } + data, err := json.Marshal(c) + if err != nil { + return + } + dir := filepath.Dir(updateCachePath()) + _ = os.MkdirAll(dir, 0o755) //nolint:gosec // G301: config dir + _ = os.WriteFile(updateCachePath(), data, 0o644) //nolint:gosec // G306: not a secret +} diff --git a/internal/commands/update_notice_test.go b/internal/commands/update_notice_test.go new file mode 100644 index 00000000..7387852a --- /dev/null +++ b/internal/commands/update_notice_test.go @@ -0,0 +1,187 @@ +package commands + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/basecamp/basecamp-cli/internal/version" +) + +func TestStartUpdateCheck_SkipsDevBuild(t *testing.T) { + origVersion := version.Version + version.Version = "dev" + defer func() { version.Version = origVersion }() + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + +func TestStartUpdateCheck_SkipsWhenEnvSet(t *testing.T) { + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + t.Setenv("BASECAMP_NO_UPDATE_CHECK", "1") + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + +func TestStartUpdateCheck_UsesFreshCache(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + // Write a fresh cache entry + cache := updateCache{ + LatestVersion: "2.0.0", + CheckedAt: time.Now().UTC(), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + notice := uc.Notice() + assert.Contains(t, notice, "2.0.0") + assert.Contains(t, notice, "1.0.0") + assert.Contains(t, notice, "basecamp upgrade") +} + +func TestStartUpdateCheck_CacheHitSameVersion(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + cache := updateCache{ + LatestVersion: "1.0.0", + CheckedAt: time.Now().UTC(), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + assert.Equal(t, "", uc.Notice()) +} + +func TestStartUpdateCheck_StaleCacheFetchesInBackground(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origChecker := versionChecker + versionChecker = func() (string, error) { return "3.0.0", nil } + defer func() { versionChecker = origChecker }() + + // Write a stale cache entry (25 hours old) + cache := updateCache{ + LatestVersion: "2.0.0", + CheckedAt: time.Now().UTC().Add(-25 * time.Hour), + } + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + data, _ := json.Marshal(cache) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), data, 0o644)) + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + // Wait for goroutine to finish + <-uc.done + + notice := uc.Notice() + assert.Contains(t, notice, "3.0.0") + + // Cache should be updated + updated := readUpdateCache() + require.NotNil(t, updated) + assert.Equal(t, "3.0.0", updated.LatestVersion) +} + +func TestStartUpdateCheck_NoCacheFetchesInBackground(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origChecker := versionChecker + versionChecker = func() (string, error) { return "1.5.0", nil } + defer func() { versionChecker = origChecker }() + + uc := StartUpdateCheck() + require.NotNil(t, uc) + + // Wait for goroutine to finish + <-uc.done + + assert.Contains(t, uc.Notice(), "1.5.0") +} + +func TestNotice_NilReceiver(t *testing.T) { + var uc *UpdateCheck + assert.Equal(t, "", uc.Notice()) +} + +func TestNotice_NonBlocking(t *testing.T) { + // Create an UpdateCheck with a channel that never closes + uc := &UpdateCheck{ + done: make(chan struct{}), + } + + // Should return empty immediately without blocking + assert.Equal(t, "", uc.Notice()) +} + +func TestUpdateCacheRoundTrip(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + writeUpdateCache("4.0.0") + + cached := readUpdateCache() + require.NotNil(t, cached) + assert.Equal(t, "4.0.0", cached.LatestVersion) + assert.WithinDuration(t, time.Now().UTC(), cached.CheckedAt, 5*time.Second) +} + +func TestReadUpdateCache_Invalid(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + cacheDir := filepath.Join(configDir, "basecamp") + require.NoError(t, os.MkdirAll(cacheDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(cacheDir, ".update-check"), []byte("not json"), 0o644)) + + assert.Nil(t, readUpdateCache()) +} + +func TestReadUpdateCache_Missing(t *testing.T) { + configDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configDir) + + assert.Nil(t, readUpdateCache()) +} From 0c95df4a4fd7b5939b575bbe5283ce7e43dc1c1c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 15:25:44 -0700 Subject: [PATCH 6/8] Address review feedback: skip non-interactive, handle clock skew --- internal/commands/update_notice.go | 27 ++++++++++++++++++----- internal/commands/update_notice_test.go | 29 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/internal/commands/update_notice.go b/internal/commands/update_notice.go index a4f3b6f9..faf49c5e 100644 --- a/internal/commands/update_notice.go +++ b/internal/commands/update_notice.go @@ -14,6 +14,15 @@ import ( // checkInterval is how often we query GitHub for the latest version. var checkInterval = 24 * time.Hour +// stdoutIsTerminal reports whether stdout is a terminal. Extracted for testability. +var stdoutIsTerminal = func() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + // UpdateCheck holds state for a non-blocking version check. type UpdateCheck struct { latest string @@ -30,14 +39,22 @@ func StartUpdateCheck() *UpdateCheck { return nil } + // Skip for non-interactive sessions — no point fetching if we won't display + if !stdoutIsTerminal() { + return nil + } + uc := &UpdateCheck{done: make(chan struct{})} cached := readUpdateCache() - if cached != nil && time.Since(cached.CheckedAt) < checkInterval { - // Cache is fresh — use it directly, no goroutine needed - uc.latest = cached.LatestVersion - close(uc.done) - return uc + if cached != nil { + age := time.Since(cached.CheckedAt) + if age >= 0 && age < checkInterval { + // Cache is fresh — use it directly, no goroutine needed + uc.latest = cached.LatestVersion + close(uc.done) + return uc + } } // Cache is stale or missing — fetch in the background diff --git a/internal/commands/update_notice_test.go b/internal/commands/update_notice_test.go index 7387852a..b5421745 100644 --- a/internal/commands/update_notice_test.go +++ b/internal/commands/update_notice_test.go @@ -33,6 +33,19 @@ func TestStartUpdateCheck_SkipsWhenEnvSet(t *testing.T) { assert.Nil(t, uc) } +func TestStartUpdateCheck_SkipsNonInteractive(t *testing.T) { + origVersion := version.Version + version.Version = "1.0.0" + defer func() { version.Version = origVersion }() + + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return false } + defer func() { stdoutIsTerminal = origTTY }() + + uc := StartUpdateCheck() + assert.Nil(t, uc) +} + func TestStartUpdateCheck_UsesFreshCache(t *testing.T) { configDir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", configDir) @@ -41,6 +54,10 @@ func TestStartUpdateCheck_UsesFreshCache(t *testing.T) { version.Version = "1.0.0" defer func() { version.Version = origVersion }() + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + // Write a fresh cache entry cache := updateCache{ LatestVersion: "2.0.0", @@ -68,6 +85,10 @@ func TestStartUpdateCheck_CacheHitSameVersion(t *testing.T) { version.Version = "1.0.0" defer func() { version.Version = origVersion }() + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + cache := updateCache{ LatestVersion: "1.0.0", CheckedAt: time.Now().UTC(), @@ -91,6 +112,10 @@ func TestStartUpdateCheck_StaleCacheFetchesInBackground(t *testing.T) { version.Version = "1.0.0" defer func() { version.Version = origVersion }() + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + origChecker := versionChecker versionChecker = func() (string, error) { return "3.0.0", nil } defer func() { versionChecker = origChecker }() @@ -128,6 +153,10 @@ func TestStartUpdateCheck_NoCacheFetchesInBackground(t *testing.T) { version.Version = "1.0.0" defer func() { version.Version = origVersion }() + origTTY := stdoutIsTerminal + stdoutIsTerminal = func() bool { return true } + defer func() { stdoutIsTerminal = origTTY }() + origChecker := versionChecker versionChecker = func() (string, error) { return "1.5.0", nil } defer func() { versionChecker = origChecker }() From 573334fe13bf0e0f0fdb0689acd47d5b9848697d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:24:40 -0700 Subject: [PATCH 7/8] Suppress update notice after upgrade command --- internal/cli/root.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index f3b245f3..91739a02 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -175,8 +175,8 @@ func NewRootCmd() *cobra.Command { } } - // Ambient update notice — only for interactive TTY sessions - if updateCheck != nil { + // Ambient update notice — only for interactive TTY sessions, skip after upgrade + if updateCheck != nil && cmd.Name() != "upgrade" { if notice := updateCheck.Notice(); notice != "" { if app != nil && app.IsInteractive() && !app.IsMachineOutput() { fmt.Fprintln(os.Stderr, notice) From ce1094f842739008799cbf99ec8507282a3fbd55 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 23 Mar 2026 16:31:29 -0700 Subject: [PATCH 8/8] Also suppress update notice after doctor command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor already has its own live version check — showing the ambient notice on top of it would be redundant. --- internal/cli/root.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cli/root.go b/internal/cli/root.go index 91739a02..e372e2a6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -175,8 +175,9 @@ func NewRootCmd() *cobra.Command { } } - // Ambient update notice — only for interactive TTY sessions, skip after upgrade - if updateCheck != nil && cmd.Name() != "upgrade" { + // Ambient update notice — only for interactive TTY sessions. + // Skip after upgrade (just acted on it) and doctor (has its own version check). + if updateCheck != nil && cmd.Name() != "upgrade" && cmd.Name() != "doctor" { if notice := updateCheck.Notice(); notice != "" { if app != nil && app.IsInteractive() && !app.IsMachineOutput() { fmt.Fprintln(os.Stderr, notice)