From 8641afd7a16c154f7d11fdb0927de07b161e2a7e Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 17:47:05 +0100 Subject: [PATCH 01/19] feat: improve version checking --- cmd/kosli/main.go | 10 +++++++++ cmd/kosli/root.go | 18 +++++++++------- cmd/kosli/root_test.go | 36 ++++++++++++++++++++++++++++++++ internal/version/update_check.go | 14 ++++++++++--- internal/version/version.go | 2 +- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/cmd/kosli/main.go b/cmd/kosli/main.go index ef60bd0cc..a18161da4 100644 --- a/cmd/kosli/main.go +++ b/cmd/kosli/main.go @@ -7,6 +7,7 @@ import ( log "github.com/kosli-dev/cli/internal/logger" "github.com/kosli-dev/cli/internal/requests" + "github.com/kosli-dev/cli/internal/version" "github.com/spf13/cobra" _ "k8s.io/client-go/plugin/pkg/client/auth" ) @@ -43,6 +44,15 @@ func main() { func innerMain(cmd *cobra.Command, args []string) error { err := cmd.Execute() if err == nil { + // Cobra handles --version internally and bypasses all hooks, so we print + // the update notice here after the fact. + if cmd.Root().Flags().Changed("version") { + notice, _ := version.CheckForUpdate(version.GetVersion()) + if notice != "" { + _, _ = fmt.Fprint(logger.ErrOut, notice) + } + } + return nil } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 655e610c1..17224f66b 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -329,13 +329,17 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { // Skip when: // - "version" subcommand: runs the check synchronously itself // - "__complete*": Cobra shell-completion commands fire on every Tab press - // - --version flag: Cobra handles it internally and skips PersistentPostRun, - // so the goroutine result would always be silently discarded - if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") && !cmd.Root().Flags().Changed("version") { - go func() { - notice, _ := version.CheckForUpdate(version.GetVersion()) - updateNoticeCh <- notice - }() + // Note: --version is handled by Cobra before any hooks run, so it never + // reaches this point; innerMain handles the notice for that case. + if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") { + f := cmd.Flags().Lookup("output") + // skip version checks if using JSON output (programmatic usage) + if f == nil || f.Value.String() != "json" { + go func() { + notice, _ := version.CheckForUpdate(version.GetVersion()) + updateNoticeCh <- notice + }() + } } if global.ApiToken == "DRY_RUN" { diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index aa65f130b..5d609dcdf 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "testing" + "github.com/kosli-dev/cli/internal/version" "github.com/stretchr/testify/suite" ) @@ -30,3 +32,37 @@ func (suite *RootCommandTestSuite) TestConfigProcessing() { func TestRootCommandTestSuite(t *testing.T) { suite.Run(t, new(RootCommandTestSuite)) } + +type UpdateNoticeTestSuite struct { + suite.Suite + defaultKosliArguments string +} + +func (suite *UpdateNoticeTestSuite) SetupTest() { + suite.defaultKosliArguments = fmt.Sprintf("--host %s --org %s --api-token %s", + global.Host, global.Org, global.ApiToken) +} + +func (suite *UpdateNoticeTestSuite) TestVersionNoticeSkippedForJSON() { + const fakeNotice = "\nA new version of the Kosli CLI is available: v9.99.0 (you have v0.0.1)\nUpgrade: https://docs.kosli.com/getting_started/install/\n" + + orig := version.OverrideCheckForUpdate + version.OverrideCheckForUpdate = func(string) (string, error) { return fakeNotice, nil } + defer func() { version.OverrideCheckForUpdate = orig }() + + // with --output json: no notice in stderr + _, _, _, stderr, err := executeCommandC( + fmt.Sprintf("list flows --output json %s", suite.defaultKosliArguments)) + suite.NoError(err) + suite.Empty(stderr) + + // with --output table: notice IS in stderr + _, _, _, stderr, err = executeCommandC( + fmt.Sprintf("list flows --output table %s", suite.defaultKosliArguments)) + suite.NoError(err) + suite.Contains(stderr, "A new version") +} + +func TestUpdateNoticeTestSuite(t *testing.T) { + suite.Run(t, new(UpdateNoticeTestSuite)) +} diff --git a/internal/version/update_check.go b/internal/version/update_check.go index 1cc9ea2e0..1ef52f11a 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -14,15 +14,21 @@ import ( const ( githubLatestReleaseURL = "https://api.github.com/repos/kosli-dev/cli/releases/latest" - updateCheckTimeout = 2 * time.Second + updateCheckTimeout = 1 * time.Second // max timeout when checking version ) type githubRelease struct { TagName string `json:"tag_name"` } +// OverrideCheckForUpdate may be set in tests to replace the real HTTP check. +var OverrideCheckForUpdate func(currentVersion string) (string, error) + // CheckForUpdate is the public entry point — uses the real GitHub URL func CheckForUpdate(currentVersion string) (string, error) { + if OverrideCheckForUpdate != nil { + return OverrideCheckForUpdate(currentVersion) + } return checkForUpdateWithURL(currentVersion, githubLatestReleaseURL) } @@ -33,11 +39,13 @@ func CheckForUpdate(currentVersion string) (string, error) { // so it never blocks or fails a command. // Set KOSLI_NO_UPDATE_CHECK=1 to skip entirely. func checkForUpdateWithURL(currentVersion string, apiURL string) (string, error) { + // checks disabled -skip if os.Getenv("KOSLI_NO_UPDATE_CHECK") != "" { return "", nil } - if currentVersion == "" || strings.HasPrefix(currentVersion, "main") || strings.Contains(currentVersion, "+unreleased") { - return "", nil // dev build — skip + // dev build — skip + if currentVersion == "" || strings.HasPrefix(currentVersion, "dev") { + return "", nil } // context provides the timeout and not http.Client diff --git a/internal/version/version.go b/internal/version/version.go index d9721821c..6678b8f57 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -11,7 +11,7 @@ var ( // // Increment major number for new feature additions and behavioral changes. // Increment minor number for bug fixes and performance enhancements. - version = "main" // this is overwritten with a release tag in the makefile + version = "dev" // this is overwritten with a release tag in the makefile // metadata is extra build time data metadata = "" From d94a6574a00c1ba3af27cf52a3b8ce1d6188e0de Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 17:57:55 +0100 Subject: [PATCH 02/19] Update internal/version/update_check.go Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- internal/version/update_check.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/version/update_check.go b/internal/version/update_check.go index 1ef52f11a..bcc480b68 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -39,7 +39,7 @@ func CheckForUpdate(currentVersion string) (string, error) { // so it never blocks or fails a command. // Set KOSLI_NO_UPDATE_CHECK=1 to skip entirely. func checkForUpdateWithURL(currentVersion string, apiURL string) (string, error) { - // checks disabled -skip + // checks disabled — skip if os.Getenv("KOSLI_NO_UPDATE_CHECK") != "" { return "", nil } From 2199a07e560a796d9ff73e21561da81f74432d61 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 18:56:43 +0100 Subject: [PATCH 03/19] feat: improve version checking - fix tests --- internal/version/update_check_test.go | 2 +- internal/version/version_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index 31cd503a9..241cf97d5 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -55,7 +55,7 @@ func TestCheckForUpdate_OptOut(t *testing.T) { func TestCheckForUpdate_DevBuild(t *testing.T) { // dev builds should be skipped without any HTTP call - notice, err := checkForUpdateWithURL("main", "http://should-not-be-called") + notice, err := checkForUpdateWithURL("dev", "http://should-not-be-called") assert.NoError(t, err) assert.Empty(t, notice) } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index 44a25fc7c..94024c9e6 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -18,7 +18,7 @@ type VersionTestSuite struct { // reset the variables before each test func (suite *VersionTestSuite) SetupTest() { - version = "main" + version = "dev" metadata = "" gitCommit = "" gitTreeState = "" @@ -37,18 +37,18 @@ func (suite *VersionTestSuite) TestGetVersion() { want string }{ { - name: "version is main when metadata is empty.", + name: "version is dev when metadata is empty.", args: args{ metadata: "", }, - want: "main", + want: "dev", }, { name: "version is suffixed with metadat when metadata is not empty.", args: args{ metadata: "bla", }, - want: "main+bla", + want: "dev+bla", }, { name: "default version is overwritten when provided and there is metadata.", From a53e5cf0d3e4ec7df325fe2fdfe54bbb8bb3874d Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 19:32:23 +0100 Subject: [PATCH 04/19] feat: improve version checking - review improvements --- cmd/kosli/root_test.go | 9 ++++++--- internal/version/update_check.go | 17 +++++++++++++---- internal/version/update_check_test.go | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index 5d609dcdf..5bc4b79af 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -39,6 +39,11 @@ type UpdateNoticeTestSuite struct { } func (suite *UpdateNoticeTestSuite) SetupTest() { + global = &GlobalOpts{ + ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY", + Org: "docs-cmd-test-user", + Host: "http://localhost:8001", + } suite.defaultKosliArguments = fmt.Sprintf("--host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken) } @@ -46,9 +51,7 @@ func (suite *UpdateNoticeTestSuite) SetupTest() { func (suite *UpdateNoticeTestSuite) TestVersionNoticeSkippedForJSON() { const fakeNotice = "\nA new version of the Kosli CLI is available: v9.99.0 (you have v0.0.1)\nUpgrade: https://docs.kosli.com/getting_started/install/\n" - orig := version.OverrideCheckForUpdate - version.OverrideCheckForUpdate = func(string) (string, error) { return fakeNotice, nil } - defer func() { version.OverrideCheckForUpdate = orig }() + defer version.SetCheckForUpdateOverride(func(string) (string, error) { return fakeNotice, nil })() // with --output json: no notice in stderr _, _, _, stderr, err := executeCommandC( diff --git a/internal/version/update_check.go b/internal/version/update_check.go index bcc480b68..f801e36dc 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -21,13 +21,22 @@ type githubRelease struct { TagName string `json:"tag_name"` } -// OverrideCheckForUpdate may be set in tests to replace the real HTTP check. -var OverrideCheckForUpdate func(currentVersion string) (string, error) +// overrideCheckForUpdate may be set by tests (via SetCheckForUpdateOverride) +// to replace the real HTTP check. +var overrideCheckForUpdate func(currentVersion string) (string, error) + +// SetCheckForUpdateOverride replaces the implementation used by CheckForUpdate +// with fn and returns a function that restores the previous value. Tests only. +func SetCheckForUpdateOverride(fn func(currentVersion string) (string, error)) func() { + old := overrideCheckForUpdate + overrideCheckForUpdate = fn + return func() { overrideCheckForUpdate = old } +} // CheckForUpdate is the public entry point — uses the real GitHub URL func CheckForUpdate(currentVersion string) (string, error) { - if OverrideCheckForUpdate != nil { - return OverrideCheckForUpdate(currentVersion) + if overrideCheckForUpdate != nil { + return overrideCheckForUpdate(currentVersion) } return checkForUpdateWithURL(currentVersion, githubLatestReleaseURL) } diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index 241cf97d5..3da26ec12 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -61,7 +61,7 @@ func TestCheckForUpdate_DevBuild(t *testing.T) { } func TestCheckForUpdate_UnreleasedBuild(t *testing.T) { - notice, err := checkForUpdateWithURL("v1.0.0+unreleased", "http://should-not-be-called") + notice, err := checkForUpdateWithURL("dev+unreleased", "http://should-not-be-called") assert.NoError(t, err) assert.Empty(t, notice) } From c81c762a571c7e4fdfd8e2cb8944d004d9d4062e Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 23:04:06 +0100 Subject: [PATCH 05/19] feat: improve version checking - fix testing --- cmd/kosli/version_test.go | 4 ++-- internal/version/update_check_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/kosli/version_test.go b/cmd/kosli/version_test.go index 52b272427..0339857e0 100644 --- a/cmd/kosli/version_test.go +++ b/cmd/kosli/version_test.go @@ -20,11 +20,11 @@ func (suite *VersionTestSuite) TestVersionCmd() { { name: "default", cmd: "version", - golden: fmt.Sprintf("version.BuildInfo{Version:\"main\", GitCommit:\"\", GitTreeState:\"\", GoVersion:\"%s\"}\n", runtime.Version()), + golden: fmt.Sprintf("version.BuildInfo{Version:\"dev\", GitCommit:\"\", GitTreeState:\"\", GoVersion:\"%s\"}\n", runtime.Version()), }, { name: "short", cmd: "version --short", - golden: "main\n", + golden: "dev\n", }, } runTestCmd(suite.T(), tests) diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index 3da26ec12..180d5a0aa 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -60,7 +60,7 @@ func TestCheckForUpdate_DevBuild(t *testing.T) { assert.Empty(t, notice) } -func TestCheckForUpdate_UnreleasedBuild(t *testing.T) { +func TestCheckForUpdate_DevBuildWithMetadata(t *testing.T) { notice, err := checkForUpdateWithURL("dev+unreleased", "http://should-not-be-called") assert.NoError(t, err) assert.Empty(t, notice) From bdf615349414e58dfa9340bf80fb898f37176a96 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 23:22:11 +0100 Subject: [PATCH 06/19] feat: improve version checking - add version test --- cmd/kosli/root_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index 5bc4b79af..d86202fe7 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + "io" "testing" "github.com/kosli-dev/cli/internal/version" @@ -48,6 +50,22 @@ func (suite *UpdateNoticeTestSuite) SetupTest() { global.Host, global.Org, global.ApiToken) } +func (suite *UpdateNoticeTestSuite) TestVersionFlagPrintsNotice() { + const fakeNotice = "\nA new version of the Kosli CLI is available: v9.99.0 (you have v0.0.1)\nUpgrade: https://docs.kosli.com/getting_started/install/\n" + defer version.SetCheckForUpdateOverride(func(string) (string, error) { return fakeNotice, nil })() + + var errBuf bytes.Buffer + origErrOut := logger.ErrOut + logger.ErrOut = &errBuf + defer func() { logger.ErrOut = origErrOut }() + + cmd, err := newRootCmd(io.Discard, &errBuf, []string{"--version"}) + suite.Require().NoError(err) + + suite.NoError(innerMain(cmd, []string{"kosli", "--version"})) + suite.Contains(errBuf.String(), "A new version") +} + func (suite *UpdateNoticeTestSuite) TestVersionNoticeSkippedForJSON() { const fakeNotice = "\nA new version of the Kosli CLI is available: v9.99.0 (you have v0.0.1)\nUpgrade: https://docs.kosli.com/getting_started/install/\n" From de2f673a68086b33a1b4ffe1f561df65d03a9a2b Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Fri, 17 Apr 2026 23:37:38 +0100 Subject: [PATCH 07/19] feat: improve version checking - fix version test --- cmd/kosli/root_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index d86202fe7..543c80baa 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -62,6 +62,7 @@ func (suite *UpdateNoticeTestSuite) TestVersionFlagPrintsNotice() { cmd, err := newRootCmd(io.Discard, &errBuf, []string{"--version"}) suite.Require().NoError(err) + cmd.SetArgs([]string{"--version"}) suite.NoError(innerMain(cmd, []string{"kosli", "--version"})) suite.Contains(errBuf.String(), "A new version") } From fc9f983893601f64ca0f2099e310c2705ccab1d8 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 13:57:11 +0100 Subject: [PATCH 08/19] chore: Update internal/version/update_check.go --- internal/version/update_check.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/version/update_check.go b/internal/version/update_check.go index f801e36dc..3db28aede 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -23,20 +23,32 @@ type githubRelease struct { // overrideCheckForUpdate may be set by tests (via SetCheckForUpdateOverride) // to replace the real HTTP check. -var overrideCheckForUpdate func(currentVersion string) (string, error) +var ( + overrideMu sync.RWMutex + overrideCheckForUpdate func(currentVersion string) (string, error) +) // SetCheckForUpdateOverride replaces the implementation used by CheckForUpdate // with fn and returns a function that restores the previous value. Tests only. func SetCheckForUpdateOverride(fn func(currentVersion string) (string, error)) func() { + overrideMu.Lock() old := overrideCheckForUpdate overrideCheckForUpdate = fn - return func() { overrideCheckForUpdate = old } + overrideMu.Unlock() + return func() { + overrideMu.Lock() + overrideCheckForUpdate = old + overrideMu.Unlock() + } } // CheckForUpdate is the public entry point — uses the real GitHub URL func CheckForUpdate(currentVersion string) (string, error) { - if overrideCheckForUpdate != nil { - return overrideCheckForUpdate(currentVersion) + overrideMu.RLock() + fn := overrideCheckForUpdate + overrideMu.RUnlock() + if fn != nil { + return fn(currentVersion) } return checkForUpdateWithURL(currentVersion, githubLatestReleaseURL) } From 70d6556764169058bace05a9cf971112ef439f7c Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 13:59:16 +0100 Subject: [PATCH 09/19] feat: improve version checking - fix missing import --- internal/version/update_check.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/version/update_check.go b/internal/version/update_check.go index 3db28aede..e8eb50130 100644 --- a/internal/version/update_check.go +++ b/internal/version/update_check.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "strings" + "sync" "time" semver "github.com/Masterminds/semver/v3" From f2a650e3273a7e0343e14d5884b465f0a35309d8 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:05:57 +0100 Subject: [PATCH 10/19] chore: Update cmd/kosli/root.go future prof machine formatting Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 17224f66b..4ef01637a 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -334,7 +334,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") { f := cmd.Flags().Lookup("output") // skip version checks if using JSON output (programmatic usage) - if f == nil || f.Value.String() != "json" { + if f == nil || f.Value.String() == "table" { go func() { notice, _ := version.CheckForUpdate(version.GetVersion()) updateNoticeCh <- notice From 3ba8e9e89ea92a4369ebe5f5fe62a9b0e672b8b6 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:12:06 +0100 Subject: [PATCH 11/19] chore: Update cmd/kosli/root.go update the comment as well Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/root.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 4ef01637a..1eb589dbb 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -332,8 +332,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { // Note: --version is handled by Cobra before any hooks run, so it never // reaches this point; innerMain handles the notice for that case. if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") { - f := cmd.Flags().Lookup("output") - // skip version checks if using JSON output (programmatic usage) + // skip version checks if not using table output (programmatic usage) if f == nil || f.Value.String() == "table" { go func() { notice, _ := version.CheckForUpdate(version.GetVersion()) From af5eed9c7bbe84177e3713067d69dd3e9373eaf5 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:16:11 +0100 Subject: [PATCH 12/19] feat: improve version checking - add race test --- internal/version/update_check_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index 180d5a0aa..fca7dfb73 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -93,3 +94,24 @@ func TestCheckForUpdate_Non200(t *testing.T) { assert.NoError(t, err) assert.Empty(t, notice) } + +func TestSetCheckForUpdateOverride_Race(t *testing.T) { + fake := func(string) (string, error) { return "notice", nil } + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = CheckForUpdate("v1.2.3") + }() + } + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + restore := SetCheckForUpdateOverride(fake) + restore() + }() + } + wg.Wait() +} From 8abb91bbb84c1e5d2c9cd99ab4f931f95356d9ed Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:18:57 +0100 Subject: [PATCH 13/19] fix: AI broken suggestion --- cmd/kosli/root.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 1eb589dbb..bf9f1fe5e 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -332,6 +332,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { // Note: --version is handled by Cobra before any hooks run, so it never // reaches this point; innerMain handles the notice for that case. if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") { + f := cmd.Flags().Lookup("output") // skip version checks if not using table output (programmatic usage) if f == nil || f.Value.String() == "table" { go func() { From 0292ec37063d6eadd8be359b696f4aca9d30fc63 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:28:53 +0100 Subject: [PATCH 14/19] feat: improve version checking - improve fragile testing --- cmd/kosli/root_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index 543c80baa..024c54c7e 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -62,8 +62,15 @@ func (suite *UpdateNoticeTestSuite) TestVersionFlagPrintsNotice() { cmd, err := newRootCmd(io.Discard, &errBuf, []string{"--version"}) suite.Require().NoError(err) + var called bool + defer version.SetCheckForUpdateOverride(func(string) (string, error) { + called = true + return fakeNotice, nil + })() + cmd.SetArgs([]string{"--version"}) suite.NoError(innerMain(cmd, []string{"kosli", "--version"})) + suite.True(called, "expected CheckForUpdate override to be called for --version") suite.Contains(errBuf.String(), "A new version") } From d0679447a01978224d8f657542b5c8fe0cd976af Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Sat, 18 Apr 2026 14:39:21 +0100 Subject: [PATCH 15/19] feat: improve version checking - remove duplicated and add empty version test --- cmd/kosli/root_test.go | 1 - internal/version/update_check_test.go | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/root_test.go b/cmd/kosli/root_test.go index 024c54c7e..9c4d13f56 100644 --- a/cmd/kosli/root_test.go +++ b/cmd/kosli/root_test.go @@ -52,7 +52,6 @@ func (suite *UpdateNoticeTestSuite) SetupTest() { func (suite *UpdateNoticeTestSuite) TestVersionFlagPrintsNotice() { const fakeNotice = "\nA new version of the Kosli CLI is available: v9.99.0 (you have v0.0.1)\nUpgrade: https://docs.kosli.com/getting_started/install/\n" - defer version.SetCheckForUpdateOverride(func(string) (string, error) { return fakeNotice, nil })() var errBuf bytes.Buffer origErrOut := logger.ErrOut diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index fca7dfb73..5dc14b397 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -54,6 +54,12 @@ func TestCheckForUpdate_OptOut(t *testing.T) { assert.Empty(t, notice) } +func TestCheckForUpdate_EmptyVersion(t *testing.T) { + notice, err := checkForUpdateWithURL("", "http://should-not-be-called") + assert.NoError(t, err) + assert.Empty(t, notice) +} + func TestCheckForUpdate_DevBuild(t *testing.T) { // dev builds should be skipped without any HTTP call notice, err := checkForUpdateWithURL("dev", "http://should-not-be-called") From f5a8a058763318f1dfa87105c5c5fdfc2df396b7 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 20 Apr 2026 16:00:34 +0100 Subject: [PATCH 16/19] feat: improve version checking - disable when in debug mode --- cmd/kosli/main.go | 11 ++++++++--- cmd/kosli/root.go | 3 ++- cmd/kosli/version.go | 9 ++++++--- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/cmd/kosli/main.go b/cmd/kosli/main.go index a18161da4..6340cc47b 100644 --- a/cmd/kosli/main.go +++ b/cmd/kosli/main.go @@ -46,10 +46,15 @@ func innerMain(cmd *cobra.Command, args []string) error { if err == nil { // Cobra handles --version internally and bypasses all hooks, so we print // the update notice here after the fact. + // initialize() also never runs, so global.Debug is not set — check + // the flag and KOSLI_DEBUG env var directly. if cmd.Root().Flags().Changed("version") { - notice, _ := version.CheckForUpdate(version.GetVersion()) - if notice != "" { - _, _ = fmt.Fprint(logger.ErrOut, notice) + debugEnabled := cmd.Root().Flags().Changed("debug") || os.Getenv("KOSLI_DEBUG") != "" + if !debugEnabled { + notice, _ := version.CheckForUpdate(version.GetVersion()) + if notice != "" { + _, _ = fmt.Fprint(logger.ErrOut, notice) + } } } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index bf9f1fe5e..9f47d0560 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -329,9 +329,10 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { // Skip when: // - "version" subcommand: runs the check synchronously itself // - "__complete*": Cobra shell-completion commands fire on every Tab press + // - debug mode: noisy HTTP traffic is undesirable when debugging // Note: --version is handled by Cobra before any hooks run, so it never // reaches this point; innerMain handles the notice for that case. - if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") { + if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") && !global.Debug { f := cmd.Flags().Lookup("output") // skip version checks if not using table output (programmatic usage) if f == nil || f.Value.String() == "table" { diff --git a/cmd/kosli/version.go b/cmd/kosli/version.go index 44af2cb01..1c2f8da01 100644 --- a/cmd/kosli/version.go +++ b/cmd/kosli/version.go @@ -47,9 +47,12 @@ func (o *versionOptions) run(out, errOut io.Writer) { // Synchronous check — version command always shows the update notice, // unlike other commands where the check may be skipped if slower than the command. - notice, _ := version.CheckForUpdate(version.GetVersion()) - if notice != "" { - _, _ = fmt.Fprint(errOut, notice) // stderr — doesn't pollute piped stdout + // Skip wehn in debug mode + if !global.Debug { + notice, _ := version.CheckForUpdate(version.GetVersion()) + if notice != "" { + _, _ = fmt.Fprint(errOut, notice) // stderr — doesn't pollute piped stdout + } } } From 81c2e79f31c93fe44cab0fae2d2286b14de9f8f1 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 20 Apr 2026 16:06:27 +0100 Subject: [PATCH 17/19] fix: Update cmd/kosli/version.go typo Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/version.go b/cmd/kosli/version.go index 1c2f8da01..a1048b9e6 100644 --- a/cmd/kosli/version.go +++ b/cmd/kosli/version.go @@ -47,7 +47,7 @@ func (o *versionOptions) run(out, errOut io.Writer) { // Synchronous check — version command always shows the update notice, // unlike other commands where the check may be skipped if slower than the command. - // Skip wehn in debug mode + // Skip when in debug mode if !global.Debug { notice, _ := version.CheckForUpdate(version.GetVersion()) if notice != "" { From 9d42e74dcd0a692fdc186a16eb20a91e074d1604 Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 20 Apr 2026 16:07:25 +0100 Subject: [PATCH 18/19] chore: Update cmd/kosli/root.go comment Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- cmd/kosli/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 9f47d0560..4b786b53c 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -334,7 +334,7 @@ func newRootCmd(out, errOut io.Writer, args []string) (*cobra.Command, error) { // reaches this point; innerMain handles the notice for that case. if cmd.Name() != "version" && !strings.HasPrefix(cmd.Name(), "__") && !global.Debug { f := cmd.Flags().Lookup("output") - // skip version checks if not using table output (programmatic usage) + // skip version checks for programmatic output (avoid polluting JSON in CI pipelines) if f == nil || f.Value.String() == "table" { go func() { notice, _ := version.CheckForUpdate(version.GetVersion()) From 8ff25e7b3345fb71c383a55f41f765cdc9fa3ffd Mon Sep 17 00:00:00 2001 From: Marko Bevc Date: Mon, 20 Apr 2026 17:01:14 +0100 Subject: [PATCH 19/19] feat: improve version checking - parse DEBUG ENV same as Viper --- cmd/kosli/main.go | 9 ++++++++- internal/version/update_check_test.go | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/kosli/main.go b/cmd/kosli/main.go index 6340cc47b..79f321655 100644 --- a/cmd/kosli/main.go +++ b/cmd/kosli/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strconv" "strings" log "github.com/kosli-dev/cli/internal/logger" @@ -49,7 +50,13 @@ func innerMain(cmd *cobra.Command, args []string) error { // initialize() also never runs, so global.Debug is not set — check // the flag and KOSLI_DEBUG env var directly. if cmd.Root().Flags().Changed("version") { - debugEnabled := cmd.Root().Flags().Changed("debug") || os.Getenv("KOSLI_DEBUG") != "" + debugEnabled := cmd.Root().Flags().Changed("debug") + // match Viper internal bool env coercion + if !debugEnabled { + if v, err := strconv.ParseBool(os.Getenv("KOSLI_DEBUG")); err == nil { + debugEnabled = v + } + } if !debugEnabled { notice, _ := version.CheckForUpdate(version.GetVersion()) if notice != "" { diff --git a/internal/version/update_check_test.go b/internal/version/update_check_test.go index 5dc14b397..aac05607d 100644 --- a/internal/version/update_check_test.go +++ b/internal/version/update_check_test.go @@ -102,6 +102,7 @@ func TestCheckForUpdate_Non200(t *testing.T) { } func TestSetCheckForUpdateOverride_Race(t *testing.T) { + t.Setenv("KOSLI_NO_UPDATE_CHECK", "1") // prevent real HTTP calls when override is momentarily nil fake := func(string) (string, error) { return "notice", nil } var wg sync.WaitGroup for i := 0; i < 50; i++ {