From 08194460f42f7eff6301adf5a2f0ca64f34ee46f Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:37:47 +0300 Subject: [PATCH 01/15] Add internal/fetch package for HTTP YAML downloads --- internal/fetch/fetch.go | 62 +++++++++++++++++++++++++ internal/fetch/fetch_test.go | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/fetch/fetch.go create mode 100644 internal/fetch/fetch_test.go diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go new file mode 100644 index 0000000..ccb4a06 --- /dev/null +++ b/internal/fetch/fetch.go @@ -0,0 +1,62 @@ +package fetch + +import ( + "context" + "fmt" + "io" + "mime" + "net/http" + "time" + + "github.com/lets-cli/lets/internal/set" +) + +var allowedContentTypes = set.NewSet( + "text/plain", + "text/yaml", + "text/x-yaml", + "application/yaml", + "application/x-yaml", +) + +var httpClient = &http.Client{ + Timeout: 15 * 60 * time.Second, +} + +// Download fetches the content at url, validates the Content-Type is a YAML variant, +// and returns the raw bytes. +func Download(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("no such file at: %s", url) + } else if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("network error: %s", resp.Status) + } + + contentType := resp.Header.Get("Content-Type") + mediaType, _, parseErr := mime.ParseMediaType(contentType) + if parseErr != nil { + mediaType = contentType + } + + if !allowedContentTypes.Contains(mediaType) { + return nil, fmt.Errorf("unsupported content type: %s", contentType) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return data, nil +} diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go new file mode 100644 index 0000000..ac104a5 --- /dev/null +++ b/internal/fetch/fetch_test.go @@ -0,0 +1,87 @@ +package fetch_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/lets-cli/lets/internal/fetch" +) + +func TestDownload(t *testing.T) { + t.Run("downloads valid yaml", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte("shell: bash\ncommands: {}")) + })) + defer srv.Close() + + data, err := fetch.Download(t.Context(), srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "shell: bash\ncommands: {}" { + t.Fatalf("unexpected data: %s", data) + } + }) + + t.Run("accepts text/plain content type", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("shell: bash")) + })) + defer srv.Close() + + _, err := fetch.Download(t.Context(), srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("errors on unsupported content type", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + _, err := fetch.Download(t.Context(), srv.URL) + if err == nil { + t.Fatal("expected error for unsupported content type") + } + if !strings.Contains(err.Error(), "unsupported content type") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("errors on 404", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetch.Download(t.Context(), srv.URL) + if err == nil { + t.Fatal("expected error for 404") + } + if !strings.Contains(err.Error(), "no such file at") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("errors on non-2xx", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + _, err := fetch.Download(t.Context(), srv.URL) + if err == nil { + t.Fatal("expected error for 500") + } + if !strings.Contains(err.Error(), "network error") { + t.Fatalf("unexpected error: %v", err) + } + }) +} From 8ff07660e5f3a588e9e6b1fa8314c98692766051 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:39:53 +0300 Subject: [PATCH 02/15] Reduce fetch httpClient timeout to 30s --- internal/fetch/fetch.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index ccb4a06..4b183b2 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -19,8 +19,9 @@ var allowedContentTypes = set.NewSet( "application/x-yaml", ) +// httpClient backstop timeout guards against hung connections when callers pass context.Background(). var httpClient = &http.Client{ - Timeout: 15 * 60 * time.Second, + Timeout: 30 * time.Second, } // Download fetches the content at url, validates the Content-Type is a YAML variant, From 1ce92471ba3003bd11b50ff52f4e34186ee95b92 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:41:13 +0300 Subject: [PATCH 03/15] Refactor RemoteMixin to use fetch.Download Remove inline HTTP download logic (allowedContentTypes var, normalizeContentType func, and the old download() body) in favour of delegating to fetch.Download. Delete the now-redundant unit tests for those helpers; equivalent coverage lives in internal/fetch/fetch_test.go. --- internal/config/config/mixin.go | 67 +----------------- internal/config/config/mixin_test.go | 102 --------------------------- 2 files changed, 2 insertions(+), 167 deletions(-) diff --git a/internal/config/config/mixin.go b/internal/config/config/mixin.go index 4b8b9c5..506083b 100644 --- a/internal/config/config/mixin.go +++ b/internal/config/config/mixin.go @@ -5,38 +5,15 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "io" - "mime" - "net/http" "os" "path/filepath" "strings" "time" - "github.com/lets-cli/lets/internal/set" + "github.com/lets-cli/lets/internal/fetch" "github.com/lets-cli/lets/internal/util" ) -var allowedContentTypes = set.NewSet( - "text/plain", - "text/yaml", - "text/x-yaml", - "application/yaml", - "application/x-yaml", -) - -// normalizeContentType extracts the media type from a Content-Type header, -// removing parameters like charset to enable robust matching. -func normalizeContentType(contentType string) string { - mediaType, _, err := mime.ParseMediaType(contentType) - if err != nil { - // If parsing fails, return the original string - return contentType - } - - return mediaType -} - type Mixins []*Mixin type Mixin struct { @@ -102,50 +79,10 @@ func (rm *RemoteMixin) tryRead() ([]byte, error) { } func (rm *RemoteMixin) download() ([]byte, error) { - // TODO: maybe create a client for this? ctx, cancel := context.WithTimeout(context.Background(), 60*5*time.Second) defer cancel() - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - rm.URL, - nil, - ) - if err != nil { - return nil, err - } - - client := &http.Client{ - Timeout: 15 * 60 * time.Second, // TODO: move to client struct - } - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to make request: %w", err) - } - - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("no such file at: %s", rm.URL) - } else if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("network error: %s", resp.Status) - } - - contentType := resp.Header.Get("Content-Type") - - normalizedContentType := normalizeContentType(contentType) - if !allowedContentTypes.Contains(normalizedContentType) { - return nil, fmt.Errorf("unsupported content type: %s", contentType) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - return data, nil + return fetch.Download(ctx, rm.URL) } // Trim `-` prefix. diff --git a/internal/config/config/mixin_test.go b/internal/config/config/mixin_test.go index 77f42d1..d912156 100644 --- a/internal/config/config/mixin_test.go +++ b/internal/config/config/mixin_test.go @@ -1,103 +1 @@ package config - -import ( - "testing" -) - -func TestNormalizeContentType(t *testing.T) { - tests := []struct { - name string - contentType string - expectedResult string - }{ - { - name: "simple content type", - contentType: "text/plain", - expectedResult: "text/plain", - }, - { - name: "content type with charset parameter", - contentType: "text/yaml; charset=utf-8", - expectedResult: "text/yaml", - }, - { - name: "content type with multiple parameters", - contentType: "application/yaml; charset=utf-8; boundary=something", - expectedResult: "application/yaml", - }, - { - name: "content type with quoted parameters", - contentType: `text/x-yaml; charset="utf-8"`, - expectedResult: "text/x-yaml", - }, - { - name: "invalid content type", - contentType: "invalid/content/type; malformed", - expectedResult: "invalid/content/type; malformed", - }, - { - name: "empty content type", - contentType: "", - expectedResult: "", - }, - { - name: "content type with spaces", - contentType: "text/plain ; charset=utf-8", - expectedResult: "text/plain", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeContentType(tt.contentType) - if result != tt.expectedResult { - t.Errorf("normalizeContentType(%q) = %q, want %q", tt.contentType, result, tt.expectedResult) - } - }) - } -} - -func TestAllowedContentTypes(t *testing.T) { - // Test that our normalization works with the allowed content types - testCases := []string{ - "text/plain", - "text/yaml", - "text/x-yaml", - "application/yaml", - "application/x-yaml", - } - - for _, contentType := range testCases { - // Test without parameters - if !allowedContentTypes.Contains(contentType) { - t.Errorf("allowedContentTypes should contain %q", contentType) - } - - // Test with charset parameter - withCharset := contentType + "; charset=utf-8" - normalized := normalizeContentType(withCharset) - if !allowedContentTypes.Contains(normalized) { - t.Errorf("normalized content type %q should be allowed (original: %q)", normalized, withCharset) - } - } - - // Test that disallowed content types are rejected - disallowedTypes := []string{ - "application/json", - "text/html", - "application/xml", - } - - for _, contentType := range disallowedTypes { - if allowedContentTypes.Contains(contentType) { - t.Errorf("allowedContentTypes should not contain %q", contentType) - } - - // Test with parameters - withCharset := contentType + "; charset=utf-8" - normalized := normalizeContentType(withCharset) - if allowedContentTypes.Contains(normalized) { - t.Errorf("normalized content type %q should not be allowed (original: %q)", normalized, withCharset) - } - } -} From 70f7ff39e1331240cd0fa20edfdd1798491f9ac2 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:43:35 +0300 Subject: [PATCH 04/15] Add --no-cache flag for remote config refresh --- internal/cli/cli.go | 5 +++++ internal/cmd/root.go | 1 + 2 files changed, 6 insertions(+) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4d680ce..7cfbc60 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -277,6 +277,7 @@ type flags struct { version bool all bool init bool + noCache bool } // We can not parse --config and --debug flags using cobra.Command.ParseFlags @@ -356,6 +357,10 @@ func parseRootFlags(args []string) (*flags, error) { if !isFlagVisited("init") { f.init = true } + case "--no-cache": + if !isFlagVisited("no-cache") { + f.noCache = true + } case "--upgrade": return nil, errors.New("--upgrade has been replaced with 'lets self upgrade'") } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c6a2f7c..cfa89cc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -108,4 +108,5 @@ func initRootFlags(rootCmd *cobra.Command) { rootCmd.Flags().CountP("debug", "d", "show debug logs (or use LETS_DEBUG=1). If used multiple times, shows more verbose logs") //nolint:lll rootCmd.Flags().StringP("config", "c", "", "config file (default is lets.yaml)") rootCmd.Flags().Bool("all", false, "show all commands (including the ones with _)") + rootCmd.Flags().Bool("no-cache", false, "re-download remote config instead of using cached version") } From 18a968534dd98a9f60a830cb363ee41fa8bc922b Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:45:55 +0300 Subject: [PATCH 05/15] Regenerate golden tests for --no-cache flag --- internal/cmd/testdata/TestHelpGolden/basic.golden | 1 + internal/cmd/testdata/TestHelpGolden/grouped_commands.golden | 1 + .../cmd/testdata/TestHelpGolden/grouped_commands_long.golden | 1 + internal/cmd/testdata/TestHelpGolden/long_command.golden | 1 + 4 files changed, 4 insertions(+) diff --git a/internal/cmd/testdata/TestHelpGolden/basic.golden b/internal/cmd/testdata/TestHelpGolden/basic.golden index c818c5e..29ebbdb 100644 --- a/internal/cmd/testdata/TestHelpGolden/basic.golden +++ b/internal/cmd/testdata/TestHelpGolden/basic.golden @@ -21,6 +21,7 @@ FLAGS --exclude Run all but excluded command(s) described in cmd as map -h --help Help for lets --init Create a new lets.yaml in the current folder + --no-cache Re-Download remote config instead of using cached version --no-depends Skip 'depends' for running command --only Run only specified command(s) described in cmd as map -v --version Version for lets diff --git a/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden b/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden index 4c58fd6..f98be8d 100644 --- a/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden +++ b/internal/cmd/testdata/TestHelpGolden/grouped_commands.golden @@ -26,6 +26,7 @@ FLAGS --exclude Run all but excluded command(s) described in cmd as map -h --help Help for lets --init Create a new lets.yaml in the current folder + --no-cache Re-Download remote config instead of using cached version --no-depends Skip 'depends' for running command --only Run only specified command(s) described in cmd as map -v --version Version for lets diff --git a/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden b/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden index ff0e017..d546f8e 100644 --- a/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden +++ b/internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden @@ -27,6 +27,7 @@ FLAGS --exclude Run all but excluded command(s) described in cmd as map -h --help Help for lets --init Create a new lets.yaml in the current folder + --no-cache Re-Download remote config instead of using cached version --no-depends Skip 'depends' for running command --only Run only specified command(s) described in cmd as map -v --version Version for lets diff --git a/internal/cmd/testdata/TestHelpGolden/long_command.golden b/internal/cmd/testdata/TestHelpGolden/long_command.golden index f21c052..78e3b85 100644 --- a/internal/cmd/testdata/TestHelpGolden/long_command.golden +++ b/internal/cmd/testdata/TestHelpGolden/long_command.golden @@ -22,6 +22,7 @@ FLAGS --exclude Run all but excluded command(s) described in cmd as map -h --help Help for lets --init Create a new lets.yaml in the current folder + --no-cache Re-Download remote config instead of using cached version --no-depends Skip 'depends' for running command --only Run only specified command(s) described in cmd as map -v --version Version for lets From d3a36285025d7d5182a56534241aecd18a8b79c5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:48:26 +0300 Subject: [PATCH 06/15] Add LoadRemote for fetching and caching remote lets.yaml Downloads a remote config by URL, caches it under ~/.config/lets/remote-configs/.yaml, and sets the working directory to the caller's CWD (not the cache dir). Falls back to cache when a --no-cache refresh fails; errors when there is no cache and the download fails. --- internal/config/load.go | 100 +++++++++++++++++++++++++++++++ internal/config/load_test.go | 111 +++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/internal/config/load.go b/internal/config/load.go index 6bf0d81..1a9b7c5 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -1,10 +1,18 @@ package config import ( + "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" + "path/filepath" "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/fetch" + "github.com/lets-cli/lets/internal/util" + "github.com/lets-cli/lets/internal/workdir" + log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) @@ -38,3 +46,95 @@ func Load(configName string, configDir string, version string) (*config.Config, return c, nil } + +// LoadRemote downloads (or loads from cache) a remote lets.yaml at url and +// returns a Config with the working directory set to the caller's CWD. +func LoadRemote(url string, noCache bool, version string) (*config.Config, error) { + cachedPath, err := ensureRemoteConfig(url, noCache) + if err != nil { + return nil, err + } + + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get working directory: %w", err) + } + + dotLetsDir, err := workdir.GetDotLetsDir(cwd) + if err != nil { + return nil, fmt.Errorf("can not get .lets path: %w", err) + } + + if err := util.SafeCreateDir(dotLetsDir); err != nil { + return nil, fmt.Errorf("can not create .lets dir: %w", err) + } + + f, err := os.Open(cachedPath) + if err != nil { + return nil, fmt.Errorf("failed to open cached remote config: %w", err) + } + defer f.Close() + + c := config.NewConfig(cwd, cachedPath, dotLetsDir) + if err := yaml.NewDecoder(f).Decode(c); err != nil { + return nil, fmt.Errorf("failed to parse remote config %s: %w", url, err) + } + + if err = validate(c, version); err != nil { + return nil, err + } + + if err := c.SetupEnv(); err != nil { + return nil, err + } + + return c, nil +} + +func ensureRemoteConfig(url string, noCache bool) (string, error) { + cacheDir, err := remoteConfigCacheDir() + if err != nil { + return "", err + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("can not create remote config cache dir: %w", err) + } + + cachePath := remoteConfigCachePath(cacheDir, url) + + if !noCache && util.FileExists(cachePath) { + return cachePath, nil + } + + data, downloadErr := fetch.Download(context.Background(), url) + if downloadErr != nil { + if util.FileExists(cachePath) { + log.Warnf("failed to refresh remote config, using cached version: %s", downloadErr) + return cachePath, nil + } + + return "", fmt.Errorf("failed to download remote config: %w", downloadErr) + } + + //#nosec G306 + if err := os.WriteFile(cachePath, data, 0o644); err != nil { + return "", fmt.Errorf("failed to cache remote config: %w", err) + } + + return cachePath, nil +} + +func remoteConfigCacheDir() (string, error) { + userDir, err := util.LetsUserDir() + if err != nil { + return "", err + } + + return filepath.Join(userDir, "remote-configs"), nil +} + +func remoteConfigCachePath(cacheDir, url string) string { + hash := sha256.Sum256([]byte(url)) + return filepath.Join(cacheDir, hex.EncodeToString(hash[:])+".yaml") +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 3b92064..3512c89 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -1,6 +1,8 @@ package config import ( + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -42,3 +44,112 @@ func TestLoadConfig(t *testing.T) { } }) } + +func TestLoadRemote(t *testing.T) { + validConfig := "shell: bash\ncommands:\n hello:\n cmd: echo hello\n" + + t.Run("downloads and caches config", func(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(validConfig)) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + + cfg, err := LoadRemote(srv.URL, false, "0.0.0-test") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := cfg.Commands["hello"]; !ok { + t.Fatal("expected hello command") + } + if requests != 1 { + t.Fatalf("expected 1 HTTP request, got %d", requests) + } + }) + + t.Run("uses cache on second call without --no-cache", func(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(validConfig)) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + + if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + t.Fatalf("first call error: %v", err) + } + if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + t.Fatalf("second call error: %v", err) + } + if requests != 1 { + t.Fatalf("expected 1 HTTP request, got %d", requests) + } + }) + + t.Run("re-downloads with --no-cache", func(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(validConfig)) + })) + defer srv.Close() + + t.Setenv("HOME", t.TempDir()) + + if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + t.Fatalf("prime cache error: %v", err) + } + if _, err := LoadRemote(srv.URL, true, "0.0.0-test"); err != nil { + t.Fatalf("no-cache call error: %v", err) + } + if requests != 2 { + t.Fatalf("expected 2 HTTP requests, got %d", requests) + } + }) + + t.Run("falls back to cache when --no-cache download fails", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write([]byte(validConfig)) + })) + + t.Setenv("HOME", t.TempDir()) + url := srv.URL + + if _, err := LoadRemote(url, false, "0.0.0-test"); err != nil { + t.Fatalf("prime cache error: %v", err) + } + + srv.Close() + + cfg, err := LoadRemote(url, true, "0.0.0-test") + if err != nil { + t.Fatalf("expected fallback to cache, got error: %v", err) + } + if _, ok := cfg.Commands["hello"]; !ok { + t.Fatal("expected hello command from cache") + } + }) + + t.Run("errors when download fails with no cache", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + srv.Close() + + t.Setenv("HOME", t.TempDir()) + + _, err := LoadRemote(srv.URL, false, "0.0.0-test") + if err == nil { + t.Fatal("expected error when no cache and download fails") + } + }) +} From 53633fe13a17d49ee3bf834f8d3233f5ed3c1371 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:52:07 +0300 Subject: [PATCH 07/15] Improve test isolation and warning message in LoadRemote --- internal/config/load.go | 2 +- internal/config/load_test.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/config/load.go b/internal/config/load.go index 1a9b7c5..d6dd0d0 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -110,7 +110,7 @@ func ensureRemoteConfig(url string, noCache bool) (string, error) { data, downloadErr := fetch.Download(context.Background(), url) if downloadErr != nil { if util.FileExists(cachePath) { - log.Warnf("failed to refresh remote config, using cached version: %s", downloadErr) + log.Warnf("failed to download remote config (%v), falling back to cached version", downloadErr) return cachePath, nil } diff --git a/internal/config/load_test.go b/internal/config/load_test.go index 3512c89..ff67091 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -58,6 +58,7 @@ func TestLoadRemote(t *testing.T) { defer srv.Close() t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) cfg, err := LoadRemote(srv.URL, false, "0.0.0-test") if err != nil { @@ -81,6 +82,7 @@ func TestLoadRemote(t *testing.T) { defer srv.Close() t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { t.Fatalf("first call error: %v", err) @@ -103,6 +105,7 @@ func TestLoadRemote(t *testing.T) { defer srv.Close() t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { t.Fatalf("prime cache error: %v", err) @@ -122,6 +125,7 @@ func TestLoadRemote(t *testing.T) { })) t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) url := srv.URL if _, err := LoadRemote(url, false, "0.0.0-test"); err != nil { @@ -146,6 +150,7 @@ func TestLoadRemote(t *testing.T) { srv.Close() t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) _, err := LoadRemote(srv.URL, false, "0.0.0-test") if err == nil { From 8950c7dddcc575c25f70a25103f2bb697180028e Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:53:54 +0300 Subject: [PATCH 08/15] Wire --no-cache flag and remote URL detection in cli.go --- internal/cli/cli.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7cfbc60..c6c40d0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -13,6 +13,7 @@ import ( "github.com/lets-cli/fang" "github.com/lets-cli/lets/internal/cmd" "github.com/lets-cli/lets/internal/config" + configpkg "github.com/lets-cli/lets/internal/config/config" "github.com/lets-cli/lets/internal/env" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" @@ -77,7 +78,12 @@ func Main(version string, buildDate string) int { rootFlags.config = os.Getenv("LETS_CONFIG") } - cfg, err := config.Load(rootFlags.config, configDir, version) + var cfg *configpkg.Config + if isRemoteURL(rootFlags.config) { + cfg, err = config.LoadRemote(rootFlags.config, rootFlags.noCache, version) + } else { + cfg, err = config.Load(rootFlags.config, configDir, version) + } if err != nil { if failOnConfigError(rootCmd, command, rootFlags) { log.Errorf("config error: %s", err) @@ -270,6 +276,10 @@ func isInteractiveStderr() bool { return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) } +func isRemoteURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + type flags struct { config string debug int From 2927545d14225da4b5ee701a9930c96c084f7467 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:56:27 +0300 Subject: [PATCH 09/15] Use loader alias for config package in cli.go for clarity --- internal/cli/cli.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6c40d0..f346bae 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -12,8 +12,8 @@ import ( "github.com/lets-cli/fang" "github.com/lets-cli/lets/internal/cmd" - "github.com/lets-cli/lets/internal/config" - configpkg "github.com/lets-cli/lets/internal/config/config" + "github.com/lets-cli/lets/internal/config/config" + loader "github.com/lets-cli/lets/internal/config" "github.com/lets-cli/lets/internal/env" "github.com/lets-cli/lets/internal/logging" "github.com/lets-cli/lets/internal/set" @@ -78,11 +78,11 @@ func Main(version string, buildDate string) int { rootFlags.config = os.Getenv("LETS_CONFIG") } - var cfg *configpkg.Config + var cfg *config.Config if isRemoteURL(rootFlags.config) { - cfg, err = config.LoadRemote(rootFlags.config, rootFlags.noCache, version) + cfg, err = loader.LoadRemote(rootFlags.config, rootFlags.noCache, version) } else { - cfg, err = config.Load(rootFlags.config, configDir, version) + cfg, err = loader.Load(rootFlags.config, configDir, version) } if err != nil { if failOnConfigError(rootCmd, command, rootFlags) { From 6322031c9df4732de4cd5a06ebed127f302748d7 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 22:58:51 +0300 Subject: [PATCH 10/15] Remove dead 5-min context timeout from RemoteMixin.download --- internal/config/config/mixin.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/internal/config/config/mixin.go b/internal/config/config/mixin.go index 506083b..2ce5a5a 100644 --- a/internal/config/config/mixin.go +++ b/internal/config/config/mixin.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/lets-cli/lets/internal/fetch" "github.com/lets-cli/lets/internal/util" @@ -79,10 +78,7 @@ func (rm *RemoteMixin) tryRead() ([]byte, error) { } func (rm *RemoteMixin) download() ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*5*time.Second) - defer cancel() - - return fetch.Download(ctx, rm.URL) + return fetch.Download(context.Background(), rm.URL) } // Trim `-` prefix. From 91a1e11589af134368d71647524e2e637c99dc00 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 23:00:04 +0300 Subject: [PATCH 11/15] Add design spec and implementation plan for remote config --- docs/specs/2026-06-13-remote-config-design.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/specs/2026-06-13-remote-config-design.md diff --git a/docs/specs/2026-06-13-remote-config-design.md b/docs/specs/2026-06-13-remote-config-design.md new file mode 100644 index 0000000..7465b16 --- /dev/null +++ b/docs/specs/2026-06-13-remote-config-design.md @@ -0,0 +1,87 @@ +--- +name: remote-config-design +description: Design for lets -c https://... remote config fetching with caching (issue #351) +--- + +# Remote Config Design + +**Issue:** [#351](https://github.com/lets-cli/lets/issues/351) + +## Summary + +Allow `lets -c https://url` to download a remote `lets.yaml`, cache it locally, and run commands from it with the invocation directory as the working directory. Add `--no-cache` flag to force re-download. + +## Architecture + +### New flag: `--no-cache` + +Added to `initRootFlags` in `internal/cmd/root.go` and parsed in `parseRootFlags` in `internal/cli/cli.go`, following the same pattern as `--debug` and `--init`. + +### URL detection in `cli.go` + +After parsing root flags, if `rootFlags.config` starts with `http://` or `https://`, call `config.LoadRemote(url, noCache, version)` instead of `config.Load(...)`. The existing `Load` path is untouched. + +### New `internal/fetch` package + +Extract HTTP download + content-type validation from `RemoteMixin.download()` into `internal/fetch/fetch.go`: + +```go +func Download(ctx context.Context, url string) ([]byte, error) +``` + +Same 15-minute timeout and content-type whitelist (`text/plain`, `text/yaml`, `text/x-yaml`, `application/yaml`, `application/x-yaml`). `RemoteMixin` becomes a thin wrapper calling `fetch.Download`. This eliminates duplication. + +### New `LoadRemote` in `internal/config/load.go` + +```go +func LoadRemote(url string, noCache bool, version string) (*config.Config, error) +``` + +- Cache path: `util.LetsUserDir()` + `remote-configs/.yaml` + - Resolves to `~/.config/lets/remote-configs/.yaml` +- Creates `~/.config/lets/remote-configs/` if needed +- Working directory: `os.Getwd()` (CWD at invocation time), passed as `configDir` to `Load` + +## Data Flow + +### Normal flow (no `--no-cache`) + +1. Cache file exists → load directly from cached path, skip HTTP +2. Cache missing → download via `fetch.Download` → persist → load from cached path + +### `--no-cache` flow + +1. Attempt download → persist (overwriting cache) → load +2. Download fails + cache exists → `log.Warnf("failed to refresh remote config, using cached version: %s", err)` → load from cache +3. Download fails + no cache → return error + +### Working directory + +`LoadRemote` calls `Load(cachedPath, cwd, version)` where `cwd = os.Getwd()`. Commands in the remote config execute in the invocation directory unless they specify `work_dir`. + +## Files Changed + +| File | Change | +|------|--------| +| `internal/fetch/fetch.go` | New — extracted `Download` func | +| `internal/fetch/fetch_test.go` | New — unit tests for fetch | +| `internal/config/config/mixin.go` | Refactor `download()` to use `fetch.Download` | +| `internal/config/load.go` | Add `LoadRemote` | +| `internal/config/load_test.go` | Add `LoadRemote` tests | +| `internal/cli/cli.go` | URL detection, call `LoadRemote`, thread `noCache` | +| `internal/cmd/root.go` | Add `--no-cache` flag | + +## Testing + +### `internal/fetch/fetch_test.go` +- Valid YAML content-type → success +- Unsupported content-type → error +- 404 → error +- Non-2xx → error + +### `internal/config/load_test.go` +- `LoadRemote` with `httptest.NewServer` serving valid YAML → loads, caches, runs from CWD +- Cache hit: serve once, call twice, assert server receives only one request +- `--no-cache` refresh: serve two different configs, assert second call gets new content +- `--no-cache` + server down + cache exists: assert warning logged + falls back to cache +- `--no-cache` + server down + no cache: assert error returned From 238ea5b8dbca850c4d449a59b4b1e5131607897f Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 23:43:33 +0300 Subject: [PATCH 12/15] Fix code review findings in remote config feature - Restore 5-minute httpClient timeout in fetch (was regressed to 30s) - Thread cancellable ctx through LoadRemote -> fetch.Download - Fix index OOB panic when --config has no following value - Add RemoteSource field to Config; set LETS_CONFIG=url, LETS_CONFIG_DIR=CWD for remote configs - Warn when LETS_CONFIG_DIR env var is set alongside a remote URL - Atomic cache write via temp-file + rename to prevent partial files - Improve corrupt-cache error to suggest --no-cache - Extract shared loadConfigFromFile helper to avoid Load/LoadRemote duplication --- internal/cli/cli.go | 8 ++- internal/config/config/config.go | 3 + internal/config/config/runtime_env.go | 12 +++- internal/config/load.go | 96 +++++++++++++++++---------- internal/config/load_test.go | 21 +++--- internal/fetch/fetch.go | 3 +- 6 files changed, 96 insertions(+), 47 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index f346bae..4d279d7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -80,7 +80,11 @@ func Main(version string, buildDate string) int { var cfg *config.Config if isRemoteURL(rootFlags.config) { - cfg, err = loader.LoadRemote(rootFlags.config, rootFlags.noCache, version) + if configDir != "" { + log.Warnf("LETS_CONFIG_DIR is ignored when using a remote config URL") + } + + cfg, err = loader.LoadRemote(ctx, rootFlags.config, rootFlags.noCache, version) } else { cfg, err = loader.Load(rootFlags.config, configDir, version) } @@ -337,7 +341,7 @@ func parseRootFlags(args []string) (*flags, error) { } f.config = value - } else if len(args[idx:]) > 0 { + } else if idx+1 < len(args) { f.config = args[idx+1] idx += 2 diff --git a/internal/config/config/config.go b/internal/config/config/config.go index 7070c2c..ad014d3 100644 --- a/internal/config/config/config.go +++ b/internal/config/config/config.go @@ -48,6 +48,9 @@ type Config struct { // absolute path to .lets/mixins MixinsDir string + // RemoteSource is the original URL when the config was loaded remotely; empty for local configs. + RemoteSource string + // cached env after config.SetupEnv, used in config.GetEnv cachedEnv map[string]string isMixin bool // if true, we consider config as mixin and apply different parsing and validation diff --git a/internal/config/config/runtime_env.go b/internal/config/config/runtime_env.go index 3159f3e..bad9c5a 100644 --- a/internal/config/config/runtime_env.go +++ b/internal/config/config/runtime_env.go @@ -7,9 +7,17 @@ import ( ) func (c *Config) BuiltinEnv(shell string) map[string]string { + letsConfig := filepath.Base(c.FilePath) + letsConfigDir := filepath.Dir(c.FilePath) + + if c.RemoteSource != "" { + letsConfig = c.RemoteSource + letsConfigDir = c.WorkDir + } + return map[string]string{ - "LETS_CONFIG": filepath.Base(c.FilePath), - "LETS_CONFIG_DIR": filepath.Dir(c.FilePath), + "LETS_CONFIG": letsConfig, + "LETS_CONFIG_DIR": letsConfigDir, "LETS_OS": runtime.GOOS, "LETS_ARCH": runtime.GOARCH, "LETS_SHELL": shell, diff --git a/internal/config/load.go b/internal/config/load.go index d6dd0d0..771d5cc 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -22,35 +22,13 @@ func Load(configName string, configDir string, version string) (*config.Config, return nil, err } - f, err := os.Open(configPath.AbsPath) - if err != nil { - return nil, err - } - - c := config.NewConfig( - configPath.WorkDir, - configPath.AbsPath, - configPath.DotLetsDir, - ) - if err := yaml.NewDecoder(f).Decode(c); err != nil { - return nil, fmt.Errorf("failed to parse %s: %w", configPath.Filename, err) - } - - if err = validate(c, version); err != nil { - return nil, err - } - - if err := c.SetupEnv(); err != nil { - return nil, err - } - - return c, nil + return loadConfigFromFile(configPath.AbsPath, configPath.WorkDir, configPath.DotLetsDir, configPath.Filename, version) } // LoadRemote downloads (or loads from cache) a remote lets.yaml at url and // returns a Config with the working directory set to the caller's CWD. -func LoadRemote(url string, noCache bool, version string) (*config.Config, error) { - cachedPath, err := ensureRemoteConfig(url, noCache) +func LoadRemote(ctx context.Context, url string, noCache bool, version string) (*config.Config, error) { + cachedPath, err := ensureRemoteConfig(ctx, url, noCache) if err != nil { return nil, err } @@ -69,15 +47,28 @@ func LoadRemote(url string, noCache bool, version string) (*config.Config, error return nil, fmt.Errorf("can not create .lets dir: %w", err) } - f, err := os.Open(cachedPath) + c, err := loadConfigFromFile(cachedPath, cwd, dotLetsDir, url, version) if err != nil { - return nil, fmt.Errorf("failed to open cached remote config: %w", err) + return nil, fmt.Errorf("%w (use --no-cache to re-download)", err) + } + + c.RemoteSource = url + + return c, nil +} + +// loadConfigFromFile is shared by Load and LoadRemote: opens the file at absPath, +// decodes YAML, validates, and sets up env. displayName appears in parse error messages. +func loadConfigFromFile(absPath, workDir, dotLetsDir, displayName, version string) (*config.Config, error) { + f, err := os.Open(absPath) + if err != nil { + return nil, err } defer f.Close() - c := config.NewConfig(cwd, cachedPath, dotLetsDir) + c := config.NewConfig(workDir, absPath, dotLetsDir) if err := yaml.NewDecoder(f).Decode(c); err != nil { - return nil, fmt.Errorf("failed to parse remote config %s: %w", url, err) + return nil, fmt.Errorf("failed to parse %s: %w", displayName, err) } if err = validate(c, version); err != nil { @@ -91,7 +82,7 @@ func LoadRemote(url string, noCache bool, version string) (*config.Config, error return c, nil } -func ensureRemoteConfig(url string, noCache bool) (string, error) { +func ensureRemoteConfig(ctx context.Context, url string, noCache bool) (string, error) { cacheDir, err := remoteConfigCacheDir() if err != nil { return "", err @@ -107,7 +98,7 @@ func ensureRemoteConfig(url string, noCache bool) (string, error) { return cachePath, nil } - data, downloadErr := fetch.Download(context.Background(), url) + data, downloadErr := fetch.Download(ctx, url) if downloadErr != nil { if util.FileExists(cachePath) { log.Warnf("failed to download remote config (%v), falling back to cached version", downloadErr) @@ -117,14 +108,51 @@ func ensureRemoteConfig(url string, noCache bool) (string, error) { return "", fmt.Errorf("failed to download remote config: %w", downloadErr) } - //#nosec G306 - if err := os.WriteFile(cachePath, data, 0o644); err != nil { - return "", fmt.Errorf("failed to cache remote config: %w", err) + if err := writeCacheAtomic(cachePath, data); err != nil { + return "", err } return cachePath, nil } +// writeCacheAtomic writes data to a sibling temp file then renames it to dst, +// ensuring the cache path is never left in a partially-written state. +func writeCacheAtomic(dst string, data []byte) error { + dir := filepath.Dir(dst) + + tmp, err := os.CreateTemp(dir, "*.yaml.tmp") + if err != nil { + return fmt.Errorf("failed to create temp cache file: %w", err) + } + + tmpPath := tmp.Name() + + _, writeErr := tmp.Write(data) + closeErr := tmp.Close() + + if writeErr != nil || closeErr != nil { + os.Remove(tmpPath) + if writeErr != nil { + return fmt.Errorf("failed to write temp cache file: %w", writeErr) + } + + return fmt.Errorf("failed to close temp cache file: %w", closeErr) + } + + //#nosec G306 + if err := os.Chmod(tmpPath, 0o644); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to chmod temp cache file: %w", err) + } + + if err := os.Rename(tmpPath, dst); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to cache remote config: %w", err) + } + + return nil +} + func remoteConfigCacheDir() (string, error) { userDir, err := util.LetsUserDir() if err != nil { diff --git a/internal/config/load_test.go b/internal/config/load_test.go index ff67091..da2df7e 100644 --- a/internal/config/load_test.go +++ b/internal/config/load_test.go @@ -1,6 +1,7 @@ package config import ( + "context" "net/http" "net/http/httptest" "os" @@ -47,6 +48,7 @@ func TestLoadConfig(t *testing.T) { func TestLoadRemote(t *testing.T) { validConfig := "shell: bash\ncommands:\n hello:\n cmd: echo hello\n" + ctx := context.Background() t.Run("downloads and caches config", func(t *testing.T) { requests := 0 @@ -60,13 +62,16 @@ func TestLoadRemote(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Chdir(t.TempDir()) - cfg, err := LoadRemote(srv.URL, false, "0.0.0-test") + cfg, err := LoadRemote(ctx, srv.URL, false, "0.0.0-test") if err != nil { t.Fatalf("unexpected error: %v", err) } if _, ok := cfg.Commands["hello"]; !ok { t.Fatal("expected hello command") } + if cfg.RemoteSource != srv.URL { + t.Fatalf("expected RemoteSource=%q, got %q", srv.URL, cfg.RemoteSource) + } if requests != 1 { t.Fatalf("expected 1 HTTP request, got %d", requests) } @@ -84,10 +89,10 @@ func TestLoadRemote(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Chdir(t.TempDir()) - if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + if _, err := LoadRemote(ctx, srv.URL, false, "0.0.0-test"); err != nil { t.Fatalf("first call error: %v", err) } - if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + if _, err := LoadRemote(ctx, srv.URL, false, "0.0.0-test"); err != nil { t.Fatalf("second call error: %v", err) } if requests != 1 { @@ -107,10 +112,10 @@ func TestLoadRemote(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Chdir(t.TempDir()) - if _, err := LoadRemote(srv.URL, false, "0.0.0-test"); err != nil { + if _, err := LoadRemote(ctx, srv.URL, false, "0.0.0-test"); err != nil { t.Fatalf("prime cache error: %v", err) } - if _, err := LoadRemote(srv.URL, true, "0.0.0-test"); err != nil { + if _, err := LoadRemote(ctx, srv.URL, true, "0.0.0-test"); err != nil { t.Fatalf("no-cache call error: %v", err) } if requests != 2 { @@ -128,13 +133,13 @@ func TestLoadRemote(t *testing.T) { t.Chdir(t.TempDir()) url := srv.URL - if _, err := LoadRemote(url, false, "0.0.0-test"); err != nil { + if _, err := LoadRemote(ctx, url, false, "0.0.0-test"); err != nil { t.Fatalf("prime cache error: %v", err) } srv.Close() - cfg, err := LoadRemote(url, true, "0.0.0-test") + cfg, err := LoadRemote(ctx, url, true, "0.0.0-test") if err != nil { t.Fatalf("expected fallback to cache, got error: %v", err) } @@ -152,7 +157,7 @@ func TestLoadRemote(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Chdir(t.TempDir()) - _, err := LoadRemote(srv.URL, false, "0.0.0-test") + _, err := LoadRemote(ctx, srv.URL, false, "0.0.0-test") if err == nil { t.Fatal("expected error when no cache and download fails") } diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 4b183b2..58968f5 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -20,8 +20,9 @@ var allowedContentTypes = set.NewSet( ) // httpClient backstop timeout guards against hung connections when callers pass context.Background(). +// 5 minutes matches the previous per-request context timeout used by RemoteMixin downloads. var httpClient = &http.Client{ - Timeout: 30 * time.Second, + Timeout: 5 * 60 * time.Second, } // Download fetches the content at url, validates the Content-Type is a YAML variant, From c579f3703743054feb4d28427c35ebd1b0f1ba0e Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sun, 14 Jun 2026 12:24:29 +0300 Subject: [PATCH 13/15] Note mixins limitation in remote config changelog entry --- docs/docs/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 30dd5c1..2dbc001 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -5,6 +5,7 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) +* `[Added]` Remote config support: `lets -c https://url` downloads and caches config to `~/.config/lets/remote-configs/`. Use `--no-cache` to force re-download. Only standalone configs (no `mixins:`) are supported for now. * `[Added]` Add `lets self skills` commands to show, install, and update the bundled lets agent skill. * `[Docs]` Document the bundled lets Agent Skill and link it from the config reference. * `[Changed]` Expand the bundled lets agent skill with config authoring guidance. From da9b5e30e0fabaab5d21ff3a770f647491f43d05 Mon Sep 17 00:00:00 2001 From: Kindritskiy Maksym Date: Sun, 14 Jun 2026 13:44:44 +0300 Subject: [PATCH 14/15] Include the URL in non-2xx HTTP error messages for better diagnosability Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- internal/fetch/fetch.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 58968f5..7299fc2 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -41,6 +41,9 @@ func Download(ctx context.Context, url string) ([]byte, error) { if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("no such file at: %s", url) + } else if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("network error for %s: %s", url, resp.Status) + } } else if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("network error: %s", resp.Status) } From 30d911172861d9e87dc1bf5734d80b406ede91c8 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sun, 14 Jun 2026 13:52:46 +0300 Subject: [PATCH 15/15] Fix duplicate else-if block in fetch.Download causing lint typecheck error --- internal/fetch/fetch.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 7299fc2..dde85fb 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -41,11 +41,9 @@ func Download(ctx context.Context, url string) ([]byte, error) { if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("no such file at: %s", url) - } else if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("network error for %s: %s", url, resp.Status) } - } else if resp.StatusCode < 200 || resp.StatusCode > 299 { - return nil, fmt.Errorf("network error: %s", resp.Status) + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, fmt.Errorf("network error for %s: %s", url, resp.Status) } contentType := resp.Header.Get("Content-Type")