From 00b31948b19db40035cec44e46f163a34c31e7cf Mon Sep 17 00:00:00 2001 From: Vandoor Date: Thu, 21 May 2026 17:27:10 +0000 Subject: [PATCH 1/2] fix: lazy cache initialization to support completion commands with invalid HOME The CLI was calling InitCache() in the init() function, which would fail when HOME was set to an invalid path (e.g., during Nix builds). This caused commands like 'replicated completion zsh' to panic even though they don't need the cache at all. This change makes cache initialization lazy - it only happens when the cache is actually needed. This allows completion commands to work in environments where HOME is not writable. Fixes #535 --- cli/cmd/cache_helper.go | 5 ++++ cli/cmd/completion_test.go | 41 ++++++++++++++++++++++++++++ cli/cmd/default_clear.go | 5 ++++ cli/cmd/default_clearall.go | 10 ++++++- cli/cmd/default_set.go | 5 ++++ cli/cmd/default_show.go | 5 ++++ cli/cmd/enterprise_portal_preview.go | 7 +++-- cli/cmd/root.go | 29 +++++++++++++++----- 8 files changed, 97 insertions(+), 10 deletions(-) diff --git a/cli/cmd/cache_helper.go b/cli/cmd/cache_helper.go index efcabd393..3bb2b972c 100644 --- a/cli/cmd/cache_helper.go +++ b/cli/cmd/cache_helper.go @@ -9,6 +9,11 @@ import ( ) func getApp(appSlugOrID string, kotsClient *kotsclient.VendorV3Client) (*types.App, error) { + cache, err := getCache() + if err != nil { + return nil, errors.Wrap(err, "initialize cache") + } + app, err := cache.GetApp(appSlugOrID) if err == nil && app != nil { return app, nil diff --git a/cli/cmd/completion_test.go b/cli/cmd/completion_test.go index 31587a020..96f654dd4 100644 --- a/cli/cmd/completion_test.go +++ b/cli/cmd/completion_test.go @@ -2,11 +2,52 @@ package cmd import ( "bytes" + "os" + "sync" "testing" "github.com/spf13/cobra" ) +// TestShellCompletionsNoHome verifies that completion commands work even when +// HOME is unset or invalid (e.g., during Nix builds). This is a regression +// test for https://github.com/replicatedhq/replicated/issues/535. +func TestShellCompletionsNoHome(t *testing.T) { + // Save and clear HOME to simulate an environment where cache initialization + // would fail. + origHome := os.Getenv("HOME") + os.Unsetenv("HOME") + os.Unsetenv("XDG_CACHE_HOME") + defer func() { + os.Setenv("HOME", origHome) + }() + + // Reset the lazy cache state so this test doesn't interfere with others. + cacheOnce = sync.Once{} + cacheInstance = nil + cacheErr = nil + defer func() { + cacheOnce = sync.Once{} + cacheInstance = nil + cacheErr = nil + }() + + parentCmd := &cobra.Command{ + Use: "replicated", + } + out := bytes.NewBufferString("") + cmd := NewCmdCompletion(out, parentCmd.Name()) + parentCmd.AddCommand(cmd) + + err := RunCompletion(out, cmd, []string{"bash"}) + if err != nil { + t.Fatalf("Unexpected error with no HOME: %v", err) + } + if out.Len() == 0 { + t.Fatalf("Output was not written") + } +} + // This unit-test was adapted from kubectl repo // Link: https://github.com/kubernetes/kubectl/blob/826006cdb947f80a679ff1eb3cb53f183a6a9bf2/pkg/cmd/completion/completion_test.go func TestShellCompletions(t *testing.T) { diff --git a/cli/cmd/default_clear.go b/cli/cmd/default_clear.go index 46aae45b3..5842c33ba 100644 --- a/cli/cmd/default_clear.go +++ b/cli/cmd/default_clear.go @@ -29,6 +29,11 @@ replicated default clear app`, } func (r *runners) clearDefault(cmd *cobra.Command, defaultType string) error { + cache, err := getCache() + if err != nil { + return errors.Wrap(err, "initialize cache") + } + if err := cache.ClearDefault(defaultType); err != nil { return errors.Wrap(err, "clear default") } diff --git a/cli/cmd/default_clearall.go b/cli/cmd/default_clearall.go index 2a979fa14..df63846da 100644 --- a/cli/cmd/default_clearall.go +++ b/cli/cmd/default_clearall.go @@ -1,6 +1,9 @@ package cmd -import "github.com/spf13/cobra" +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" +) func (r *runners) InitDefaultClearAllCommand(parent *cobra.Command) *cobra.Command { cmd := &cobra.Command{ @@ -22,6 +25,11 @@ replicated default clear-all`, } func (r *runners) clearAllDefaults(cmd *cobra.Command) error { + cache, err := getCache() + if err != nil { + return errors.Wrap(err, "initialize cache") + } + if err := cache.ClearDefault("app"); err != nil { return err } diff --git a/cli/cmd/default_set.go b/cli/cmd/default_set.go index db9ffee6c..aa49e91bf 100644 --- a/cli/cmd/default_set.go +++ b/cli/cmd/default_set.go @@ -44,6 +44,11 @@ func (r *runners) setDefault(cmd *cobra.Command, defaultType string, defaultValu return errors.Wrap(err, "get app") } + cache, err := getCache() + if err != nil { + return errors.Wrap(err, "initialize cache") + } + if err := cache.SetDefault(defaultType, defaultValue); err != nil { return errors.Wrap(err, "set default in cache") } diff --git a/cli/cmd/default_show.go b/cli/cmd/default_show.go index 5bcc9a110..183540282 100644 --- a/cli/cmd/default_show.go +++ b/cli/cmd/default_show.go @@ -40,6 +40,11 @@ replicated default show app } func (r *runners) showDefault(cmd *cobra.Command, defaultType string, outputFormat string) error { + cache, err := getCache() + if err != nil { + return errors.Wrap(err, "initialize cache") + } + defaultValue, err := cache.GetDefault(defaultType) if err != nil { return errors.Wrap(err, "get default value") diff --git a/cli/cmd/enterprise_portal_preview.go b/cli/cmd/enterprise_portal_preview.go index 73a299a5d..f1e7408f0 100644 --- a/cli/cmd/enterprise_portal_preview.go +++ b/cli/cmd/enterprise_portal_preview.go @@ -106,8 +106,11 @@ func (r *runners) enterprisePortalPreview(cmd *cobra.Command, args []string) err // 2. default app set via `replicated default app ` (cache.DefaultApp) // 3. REPLICATED_APP env var appSlug := appSlugOrID - if appSlug == "" && cache != nil { - appSlug = cache.DefaultApp + if appSlug == "" { + cache, err := getCache() + if err == nil && cache != nil { + appSlug = cache.DefaultApp + } } if appSlug == "" { appSlug = os.Getenv("REPLICATED_APP") diff --git a/cli/cmd/root.go b/cli/cmd/root.go index b2268218e..fb1467b66 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -6,6 +6,7 @@ import ( "io" "os" "strings" + "sync" "text/tabwriter" "github.com/Masterminds/sprig/v3" @@ -35,7 +36,9 @@ var ( profileNameFlag string platformOrigin = "https://api.replicated.com/vendor" kurlDotSHOrigin = "https://kurl.sh" - cache *replicatedcache.Cache + cacheInstance *replicatedcache.Cache + cacheOnce sync.Once + cacheErr error debugFlag bool ) @@ -45,12 +48,6 @@ func init() { platformOrigin = originFromEnv } - c, err := replicatedcache.InitCache() - if err != nil { - panic(err) - } - cache = c - // Set debug mode from environment variable if os.Getenv("REPLICATED_DEBUG") == "1" || os.Getenv("REPLICATED_DEBUG") == "true" { debugFlag = true @@ -58,6 +55,19 @@ func init() { } } +// getCache returns the singleton cache instance, initializing it on first call. +// This lazy initialization ensures that commands like 'completion' which don't +// need the cache won't fail when HOME is not writable (e.g., during Nix builds). +func getCache() (*replicatedcache.Cache, error) { + cacheOnce.Do(func() { + cacheInstance, cacheErr = replicatedcache.InitCache() + }) + if cacheErr != nil { + return nil, cacheErr + } + return cacheInstance, nil +} + // RootCmd represents the base command when called without any subcommands func GetRootCmd() *cobra.Command { rootCmd := &cobra.Command{ @@ -446,6 +456,11 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i return errors.Wrap(err, "set up APIs") } + cache, err := getCache() + if err != nil { + return errors.Wrap(err, "initialize cache") + } + if appSlugOrID == "" { if cache.DefaultApp != "" { appSlugOrID = cache.DefaultApp From 3b3f23bd06d9b5b2884e4e5a3903a5927883710d Mon Sep 17 00:00:00 2001 From: Marc Campbell Date: Thu, 21 May 2026 10:59:21 -0700 Subject: [PATCH 2/2] refactor: move cache singleton into pkg/cache package Per review feedback, the lazy-init singleton state (once/instance/err) and GetInstance helper now live in the cache package itself, removing the ambiguity between local cache variables, the former global cacheInstance, and the replicatedcache import alias. Call sites use cache.GetInstance(); tests use cache.ResetForTesting(). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cli/cmd/cache_helper.go | 3 ++- cli/cmd/completion_test.go | 12 +++--------- cli/cmd/default_clear.go | 3 ++- cli/cmd/default_clearall.go | 3 ++- cli/cmd/default_set.go | 3 ++- cli/cmd/default_show.go | 3 ++- cli/cmd/enterprise_portal_preview.go | 3 ++- cli/cmd/root.go | 19 +------------------ pkg/cache/cache.go | 27 +++++++++++++++++++++++++++ 9 files changed, 43 insertions(+), 33 deletions(-) diff --git a/cli/cmd/cache_helper.go b/cli/cmd/cache_helper.go index 3bb2b972c..ce8a1deb3 100644 --- a/cli/cmd/cache_helper.go +++ b/cli/cmd/cache_helper.go @@ -4,12 +4,13 @@ import ( "context" "github.com/pkg/errors" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/replicatedhq/replicated/pkg/kotsclient" "github.com/replicatedhq/replicated/pkg/types" ) func getApp(appSlugOrID string, kotsClient *kotsclient.VendorV3Client) (*types.App, error) { - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return nil, errors.Wrap(err, "initialize cache") } diff --git a/cli/cmd/completion_test.go b/cli/cmd/completion_test.go index 96f654dd4..9e73693c3 100644 --- a/cli/cmd/completion_test.go +++ b/cli/cmd/completion_test.go @@ -3,9 +3,9 @@ package cmd import ( "bytes" "os" - "sync" "testing" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/spf13/cobra" ) @@ -23,14 +23,8 @@ func TestShellCompletionsNoHome(t *testing.T) { }() // Reset the lazy cache state so this test doesn't interfere with others. - cacheOnce = sync.Once{} - cacheInstance = nil - cacheErr = nil - defer func() { - cacheOnce = sync.Once{} - cacheInstance = nil - cacheErr = nil - }() + replicatedcache.ResetForTesting() + defer replicatedcache.ResetForTesting() parentCmd := &cobra.Command{ Use: "replicated", diff --git a/cli/cmd/default_clear.go b/cli/cmd/default_clear.go index 5842c33ba..0ff9ccec7 100644 --- a/cli/cmd/default_clear.go +++ b/cli/cmd/default_clear.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/pkg/errors" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/spf13/cobra" ) @@ -29,7 +30,7 @@ replicated default clear app`, } func (r *runners) clearDefault(cmd *cobra.Command, defaultType string) error { - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return errors.Wrap(err, "initialize cache") } diff --git a/cli/cmd/default_clearall.go b/cli/cmd/default_clearall.go index df63846da..db8e7b397 100644 --- a/cli/cmd/default_clearall.go +++ b/cli/cmd/default_clearall.go @@ -2,6 +2,7 @@ package cmd import ( "github.com/pkg/errors" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/spf13/cobra" ) @@ -25,7 +26,7 @@ replicated default clear-all`, } func (r *runners) clearAllDefaults(cmd *cobra.Command) error { - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return errors.Wrap(err, "initialize cache") } diff --git a/cli/cmd/default_set.go b/cli/cmd/default_set.go index aa49e91bf..804c02035 100644 --- a/cli/cmd/default_set.go +++ b/cli/cmd/default_set.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/pkg/errors" "github.com/replicatedhq/replicated/cli/print" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/replicatedhq/replicated/pkg/types" "github.com/spf13/cobra" ) @@ -44,7 +45,7 @@ func (r *runners) setDefault(cmd *cobra.Command, defaultType string, defaultValu return errors.Wrap(err, "get app") } - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return errors.Wrap(err, "initialize cache") } diff --git a/cli/cmd/default_show.go b/cli/cmd/default_show.go index 183540282..347060ff2 100644 --- a/cli/cmd/default_show.go +++ b/cli/cmd/default_show.go @@ -5,6 +5,7 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/replicated/cli/print" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/replicatedhq/replicated/pkg/types" "github.com/spf13/cobra" ) @@ -40,7 +41,7 @@ replicated default show app } func (r *runners) showDefault(cmd *cobra.Command, defaultType string, outputFormat string) error { - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return errors.Wrap(err, "initialize cache") } diff --git a/cli/cmd/enterprise_portal_preview.go b/cli/cmd/enterprise_portal_preview.go index f1e7408f0..eaf9033e2 100644 --- a/cli/cmd/enterprise_portal_preview.go +++ b/cli/cmd/enterprise_portal_preview.go @@ -15,6 +15,7 @@ import ( "time" "github.com/pkg/errors" + replicatedcache "github.com/replicatedhq/replicated/pkg/cache" "github.com/replicatedhq/replicated/pkg/credentials" "github.com/replicatedhq/replicated/pkg/kotsclient" "github.com/replicatedhq/replicated/pkg/platformclient" @@ -107,7 +108,7 @@ func (r *runners) enterprisePortalPreview(cmd *cobra.Command, args []string) err // 3. REPLICATED_APP env var appSlug := appSlugOrID if appSlug == "" { - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err == nil && cache != nil { appSlug = cache.DefaultApp } diff --git a/cli/cmd/root.go b/cli/cmd/root.go index fb1467b66..196c497ff 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -6,7 +6,6 @@ import ( "io" "os" "strings" - "sync" "text/tabwriter" "github.com/Masterminds/sprig/v3" @@ -36,9 +35,6 @@ var ( profileNameFlag string platformOrigin = "https://api.replicated.com/vendor" kurlDotSHOrigin = "https://kurl.sh" - cacheInstance *replicatedcache.Cache - cacheOnce sync.Once - cacheErr error debugFlag bool ) @@ -55,19 +51,6 @@ func init() { } } -// getCache returns the singleton cache instance, initializing it on first call. -// This lazy initialization ensures that commands like 'completion' which don't -// need the cache won't fail when HOME is not writable (e.g., during Nix builds). -func getCache() (*replicatedcache.Cache, error) { - cacheOnce.Do(func() { - cacheInstance, cacheErr = replicatedcache.InitCache() - }) - if cacheErr != nil { - return nil, cacheErr - } - return cacheInstance, nil -} - // RootCmd represents the base command when called without any subcommands func GetRootCmd() *cobra.Command { rootCmd := &cobra.Command{ @@ -456,7 +439,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i return errors.Wrap(err, "set up APIs") } - cache, err := getCache() + cache, err := replicatedcache.GetInstance() if err != nil { return errors.Wrap(err, "initialize cache") } diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go index e53de1b69..01dc441cc 100644 --- a/pkg/cache/cache.go +++ b/pkg/cache/cache.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "sync" "time" "github.com/adrg/xdg" @@ -11,6 +12,32 @@ import ( "github.com/replicatedhq/replicated/pkg/types" ) +var ( + instance *Cache + instanceErr error + once sync.Once +) + +// GetInstance returns the singleton cache instance, initializing it on first +// call. Lazy initialization ensures commands that don't need the cache (e.g. +// 'completion') won't fail when HOME is not writable. +func GetInstance() (*Cache, error) { + once.Do(func() { + instance, instanceErr = InitCache() + }) + if instanceErr != nil { + return nil, instanceErr + } + return instance, nil +} + +// ResetForTesting resets the singleton so tests can control cache state. +func ResetForTesting() { + once = sync.Once{} + instance = nil + instanceErr = nil +} + const cacheFileName = "replicated.cache" type Cache struct {