From a588bc396b1c651ce0a7e9ec54c50230d0377af8 Mon Sep 17 00:00:00 2001 From: Andrea Pinto Date: Wed, 15 Apr 2026 15:56:56 +0200 Subject: [PATCH 1/2] warn when CLI version is below API minimum Check x-notte-min-cli-version response header and print a one-time stderr warning when the running CLI is outdated. Prevents silent data loss from schema mismatches. --- internal/api/client.go | 45 ++++++++++++++++++++ internal/api/client_test.go | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/internal/api/client.go b/internal/api/client.go index 9127b07..34bc60b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,14 +4,29 @@ import ( "context" "crypto/tls" "fmt" + "io" "net/http" + "os" + "sync" "time" notteErrors "github.com/nottelabs/notte-cli/internal/errors" + "github.com/nottelabs/notte-cli/internal/update" ) const DefaultBaseURL = "https://api.notte.cc" +// MinCLIVersionHeader is the response header the API uses to advertise the +// minimum CLI version required for full compatibility. +const MinCLIVersionHeader = "x-notte-min-cli-version" + +// versionWarningOnce ensures the outdated-CLI warning prints at most once per process. +var versionWarningOnce sync.Once + +// versionWarningWriter is the destination for the version mismatch warning. +// Defaults to os.Stderr; overridden in tests. +var versionWarningWriter io.Writer + // NotteClient wraps the generated client with auth and resilience type NotteClient struct { client *ClientWithResponses @@ -142,9 +157,39 @@ func (t *resilientTransport) RoundTrip(req *http.Request) (*http.Response, error t.circuitBreaker.RecordSuccess() } + // Check if the API requires a newer CLI version + t.checkMinVersion(resp) + return resp, nil } +// checkMinVersion inspects the x-notte-min-cli-version response header and +// prints a single stderr warning if the running CLI is older than required. +func (t *resilientTransport) checkMinVersion(resp *http.Response) { + minVersion := resp.Header.Get(MinCLIVersionHeader) + if minVersion == "" || t.version == "" || t.version == "dev" { + return + } + + outdated, err := update.IsNewer(t.version, minVersion) + if err != nil || !outdated { + return + } + + versionWarningOnce.Do(func() { + w := versionWarningWriter + if w == nil { + w = os.Stderr + } + fmt.Fprintf(w, + "\nWarning: this CLI version (%s) is older than the minimum required by the API (%s).\n"+ + "Some commands may return incomplete or incorrect results.\n"+ + "Run `brew upgrade notte` or visit https://github.com/nottelabs/notte-cli/releases to update.\n\n", + t.version, minVersion, + ) + }) +} + func (t *resilientTransport) doWithRetry(req *http.Request) (*http.Response, error) { var resp *http.Response var err error diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 121ecc7..3dd0f7c 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" ) @@ -389,6 +390,88 @@ func TestNotteClient_APIKey(t *testing.T) { } } +func TestCheckMinVersion_WarnsWhenOutdated(t *testing.T) { + versionWarningOnce = sync.Once{} + var buf strings.Builder + versionWarningWriter = &buf + t.Cleanup(func() { + versionWarningOnce = sync.Once{} + versionWarningWriter = nil + }) + + rt := &resilientTransport{version: "0.0.10"} + resp := &http.Response{ + Header: http.Header{"X-Notte-Min-Cli-Version": []string{"0.0.12"}}, + } + rt.checkMinVersion(resp) + + output := buf.String() + if !strings.Contains(output, "older than the minimum required") { + t.Errorf("expected outdated warning, got: %q", output) + } + if !strings.Contains(output, "0.0.10") || !strings.Contains(output, "0.0.12") { + t.Errorf("warning should mention both versions, got: %q", output) + } +} + +func TestCheckMinVersion_SilentWhenCurrent(t *testing.T) { + versionWarningOnce = sync.Once{} + var buf strings.Builder + versionWarningWriter = &buf + t.Cleanup(func() { + versionWarningOnce = sync.Once{} + versionWarningWriter = nil + }) + + rt := &resilientTransport{version: "0.0.12"} + resp := &http.Response{ + Header: http.Header{"X-Notte-Min-Cli-Version": []string{"0.0.12"}}, + } + rt.checkMinVersion(resp) + + if buf.Len() > 0 { + t.Errorf("expected no output for current version, got: %q", buf.String()) + } +} + +func TestCheckMinVersion_SilentWhenNoHeader(t *testing.T) { + versionWarningOnce = sync.Once{} + var buf strings.Builder + versionWarningWriter = &buf + t.Cleanup(func() { + versionWarningOnce = sync.Once{} + versionWarningWriter = nil + }) + + rt := &resilientTransport{version: "0.0.10"} + resp := &http.Response{Header: http.Header{}} + rt.checkMinVersion(resp) + + if buf.Len() > 0 { + t.Errorf("expected no output without header, got: %q", buf.String()) + } +} + +func TestCheckMinVersion_SkipsDevVersion(t *testing.T) { + versionWarningOnce = sync.Once{} + var buf strings.Builder + versionWarningWriter = &buf + t.Cleanup(func() { + versionWarningOnce = sync.Once{} + versionWarningWriter = nil + }) + + rt := &resilientTransport{version: "dev"} + resp := &http.Response{ + Header: http.Header{"X-Notte-Min-Cli-Version": []string{"0.0.12"}}, + } + rt.checkMinVersion(resp) + + if buf.Len() > 0 { + t.Errorf("expected no output for dev version, got: %q", buf.String()) + } +} + func TestDefaultContext(t *testing.T) { ctx := DefaultContext() if ctx == nil { From 1813267d1924932fbd1584b488c0741fdf8a5686 Mon Sep 17 00:00:00 2001 From: Andrea Pinto Date: Wed, 15 Apr 2026 17:07:19 +0200 Subject: [PATCH 2/2] prompt upgrade instead of just warning on version mismatch Reuse the existing update.PrintUpdateNotification flow so the user gets an interactive Y/n upgrade prompt (or a non-blocking notice in non-TTY/agent contexts) when the API returns a minimum CLI version higher than the running one. --- internal/api/client.go | 38 ++++++++------------ internal/api/client_test.go | 69 ++++++++++++++----------------------- internal/cmd/root.go | 11 ++++-- 3 files changed, 49 insertions(+), 69 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index 34bc60b..938c1be 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -4,9 +4,7 @@ import ( "context" "crypto/tls" "fmt" - "io" "net/http" - "os" "sync" "time" @@ -20,12 +18,16 @@ const DefaultBaseURL = "https://api.notte.cc" // minimum CLI version required for full compatibility. const MinCLIVersionHeader = "x-notte-min-cli-version" -// versionWarningOnce ensures the outdated-CLI warning prints at most once per process. -var versionWarningOnce sync.Once +// detectedMinVersion stores the minimum CLI version returned by the API. +// Set once by checkMinVersion; read by root.go to trigger the upgrade prompt. +var detectedMinVersion string +var detectOnce sync.Once -// versionWarningWriter is the destination for the version mismatch warning. -// Defaults to os.Stderr; overridden in tests. -var versionWarningWriter io.Writer +// DetectedMinVersion returns the API-required minimum CLI version, or "" if +// the header was not seen or the CLI is already up to date. +func DetectedMinVersion() string { + return detectedMinVersion +} // NotteClient wraps the generated client with auth and resilience type NotteClient struct { @@ -164,29 +166,19 @@ func (t *resilientTransport) RoundTrip(req *http.Request) (*http.Response, error } // checkMinVersion inspects the x-notte-min-cli-version response header and -// prints a single stderr warning if the running CLI is older than required. +// stores the required version so the root command can prompt for upgrade. func (t *resilientTransport) checkMinVersion(resp *http.Response) { minVersion := resp.Header.Get(MinCLIVersionHeader) if minVersion == "" || t.version == "" || t.version == "dev" { return } - outdated, err := update.IsNewer(t.version, minVersion) - if err != nil || !outdated { - return - } - - versionWarningOnce.Do(func() { - w := versionWarningWriter - if w == nil { - w = os.Stderr + detectOnce.Do(func() { + outdated, err := update.IsNewer(t.version, minVersion) + if err != nil || !outdated { + return } - fmt.Fprintf(w, - "\nWarning: this CLI version (%s) is older than the minimum required by the API (%s).\n"+ - "Some commands may return incomplete or incorrect results.\n"+ - "Run `brew upgrade notte` or visit https://github.com/nottelabs/notte-cli/releases to update.\n\n", - t.version, minVersion, - ) + detectedMinVersion = minVersion }) } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 3dd0f7c..afc1e8d 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -390,14 +390,14 @@ func TestNotteClient_APIKey(t *testing.T) { } } -func TestCheckMinVersion_WarnsWhenOutdated(t *testing.T) { - versionWarningOnce = sync.Once{} - var buf strings.Builder - versionWarningWriter = &buf - t.Cleanup(func() { - versionWarningOnce = sync.Once{} - versionWarningWriter = nil - }) +func resetDetectOnce() { + detectOnce = sync.Once{} + detectedMinVersion = "" +} + +func TestCheckMinVersion_StoresWhenOutdated(t *testing.T) { + resetDetectOnce() + t.Cleanup(resetDetectOnce) rt := &resilientTransport{version: "0.0.10"} resp := &http.Response{ @@ -405,23 +405,14 @@ func TestCheckMinVersion_WarnsWhenOutdated(t *testing.T) { } rt.checkMinVersion(resp) - output := buf.String() - if !strings.Contains(output, "older than the minimum required") { - t.Errorf("expected outdated warning, got: %q", output) - } - if !strings.Contains(output, "0.0.10") || !strings.Contains(output, "0.0.12") { - t.Errorf("warning should mention both versions, got: %q", output) + if got := DetectedMinVersion(); got != "0.0.12" { + t.Errorf("DetectedMinVersion() = %q, want %q", got, "0.0.12") } } -func TestCheckMinVersion_SilentWhenCurrent(t *testing.T) { - versionWarningOnce = sync.Once{} - var buf strings.Builder - versionWarningWriter = &buf - t.Cleanup(func() { - versionWarningOnce = sync.Once{} - versionWarningWriter = nil - }) +func TestCheckMinVersion_EmptyWhenCurrent(t *testing.T) { + resetDetectOnce() + t.Cleanup(resetDetectOnce) rt := &resilientTransport{version: "0.0.12"} resp := &http.Response{ @@ -429,37 +420,27 @@ func TestCheckMinVersion_SilentWhenCurrent(t *testing.T) { } rt.checkMinVersion(resp) - if buf.Len() > 0 { - t.Errorf("expected no output for current version, got: %q", buf.String()) + if got := DetectedMinVersion(); got != "" { + t.Errorf("DetectedMinVersion() = %q, want empty", got) } } -func TestCheckMinVersion_SilentWhenNoHeader(t *testing.T) { - versionWarningOnce = sync.Once{} - var buf strings.Builder - versionWarningWriter = &buf - t.Cleanup(func() { - versionWarningOnce = sync.Once{} - versionWarningWriter = nil - }) +func TestCheckMinVersion_EmptyWhenNoHeader(t *testing.T) { + resetDetectOnce() + t.Cleanup(resetDetectOnce) rt := &resilientTransport{version: "0.0.10"} resp := &http.Response{Header: http.Header{}} rt.checkMinVersion(resp) - if buf.Len() > 0 { - t.Errorf("expected no output without header, got: %q", buf.String()) + if got := DetectedMinVersion(); got != "" { + t.Errorf("DetectedMinVersion() = %q, want empty", got) } } -func TestCheckMinVersion_SkipsDevVersion(t *testing.T) { - versionWarningOnce = sync.Once{} - var buf strings.Builder - versionWarningWriter = &buf - t.Cleanup(func() { - versionWarningOnce = sync.Once{} - versionWarningWriter = nil - }) +func TestCheckMinVersion_EmptyForDevVersion(t *testing.T) { + resetDetectOnce() + t.Cleanup(resetDetectOnce) rt := &resilientTransport{version: "dev"} resp := &http.Response{ @@ -467,8 +448,8 @@ func TestCheckMinVersion_SkipsDevVersion(t *testing.T) { } rt.checkMinVersion(resp) - if buf.Len() > 0 { - t.Errorf("expected no output for dev version, got: %q", buf.String()) + if got := DetectedMinVersion(); got != "" { + t.Errorf("DetectedMinVersion() = %q, want empty", got) } } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f34b081..37ee2fa 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -54,8 +54,15 @@ func Execute() { err := rootCmd.Execute() - // Show update notification after command output - if checker != nil { + // If the API told us our CLI is too old, prompt to upgrade (takes priority) + if minVer := api.DetectedMinVersion(); minVer != "" { + update.PrintUpdateNotification(&update.Result{ + CurrentVersion: Version, + LatestVersion: minVer, + UpdateAvailable: true, + }, os.Stderr, os.Stdin, yesFlag, IsJSONOutput(), noColor) + } else if checker != nil { + // Otherwise fall back to the GitHub-based update check if result := checker.GetResult(); result != nil { update.PrintUpdateNotification(result, os.Stderr, os.Stdin, yesFlag, IsJSONOutput(), noColor) }