Skip to content

Add remote config support (lets -c https://...)#357

Open
kindermax wants to merge 13 commits into
masterfrom
remote-config
Open

Add remote config support (lets -c https://...)#357
kindermax wants to merge 13 commits into
masterfrom
remote-config

Conversation

@kindermax

@kindermax kindermax commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add lets -c https://url support: downloads and caches remote lets.yaml to ~/.config/lets/remote-configs/<sha256>.yaml
  • Add --no-cache flag to force re-download; falls back to cached version if download fails with a warning
  • Extract shared HTTP download logic into new internal/fetch package, reused by remote mixins and the new LoadRemote path
  • Commands from remote configs run with the user's CWD as working directory (unless the command specifies work_dir)
  • LETS_CONFIG is set to the original URL; LETS_CONFIG_DIR is set to CWD (not the cache directory)

Limitations

  • Remote configs do not support mixins — only standalone configs (no mixins: key) are supported for now

Implementation notes

  • Cache is keyed by sha256(url) — stable and collision-resistant; use --no-cache to pick up URL updates
  • Cache write is atomic (temp file + rename) to prevent corrupt state on interrupted downloads
  • ctx from SIGINT/SIGTERM is propagated through LoadRemote so Ctrl+C cancels in-progress downloads
  • LETS_CONFIG_DIR env var is ignored (with a warning) when using a remote URL

Test Plan

  • go test ./... passes (all packages green)
  • lets -c https://<raw-yaml-url> <command> downloads config on first run
  • Second run without --no-cache uses cached file (no HTTP request)
  • lets -c https://<url> --no-cache <command> re-downloads
  • With server down + cache present: warns and falls back to cache
  • With server down + no cache: fails with clear error
  • lets --help shows --no-cache flag
  • Ctrl+C during download exits promptly (does not hang for 30s)
  • $LETS_CONFIG in a remote command equals the original URL
  • $LETS_CONFIG_DIR in a remote command equals the invocation CWD

@sourcery-ai

sourcery-ai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds support for loading lets configs from remote HTTP(S) URLs with caching, centralizes HTTP/YAML download logic in a new fetch package, and wires a new --no-cache flag through the CLI and config loader to control re-download behavior.

Sequence diagram for loading remote config with caching and --no-cache

sequenceDiagram
    actor User
    participant CLI as cli.Main
    participant Loader as config.LoadRemote
    participant CacheFn as ensureRemoteConfig
    participant Fetch as fetch.Download

    User->>CLI: lets -c https://url [--no-cache]
    CLI->>Loader: LoadRemote(url, noCache, version)
    Loader->>CacheFn: ensureRemoteConfig(url, noCache)

    alt noCache is false and cache exists
        CacheFn-->>Loader: return cachedPath
    else cache missing or noCache is true
        CacheFn->>Fetch: Download(context.Background, url)
        alt Download succeeds
            Fetch-->>CacheFn: data
            CacheFn->>CacheFn: os.WriteFile(cachePath, data, 0o644)
            CacheFn-->>Loader: return cachePath
        else Download fails and cache exists
            CacheFn-->>Loader: return cachePath
        else Download fails and no cache
            CacheFn-->>Loader: error
            Loader-->>CLI: error
            CLI-->>User: config error
        end
    end

    Loader->>Loader: yaml.NewDecoder.Decode
    Loader->>Loader: validate
    Loader->>Loader: Config.SetupEnv
    Loader-->>CLI: *config.Config
    CLI-->>User: runs requested command from remote config
Loading

File-Level Changes

Change Details Files
Implement remote config loading with per-URL cache under ~/.config/lets/remote-configs and ensure commands run relative to the caller's CWD.
  • Add LoadRemote in the config loader to download or load-from-cache a remote lets.yaml, then parse, validate, and setup the config using the caller's working directory and .lets dir.
  • Introduce ensureRemoteConfig, remoteConfigCacheDir, and remoteConfigCachePath helpers to manage cache directory creation, filename hashing via SHA-256 of the URL, file existence checks, download, and write-to-disk with fallback to cache on download failure.
  • Add unit tests in load_test.go to cover first-time download, cache reuse, forced re-download via no-cache, fallback-to-cache on download failure, and erroring when both download and cache are unavailable.
internal/config/load.go
internal/config/load_test.go
Extract shared HTTP download logic for YAML configs into a reusable internal/fetch package and refactor remote mixins to use it.
  • Create internal/fetch.Download, which performs an HTTP GET with a shared http.Client, validates 2xx responses (with a 404 special-case), normalizes and checks Content-Type against a YAML/text allowlist, and returns response bytes.
  • Move content-type normalization and allowed-type enforcement from the mixin implementation into the new fetch package, simplifying callers.
  • Update RemoteMixin.download to delegate to fetch.Download and remove now-redundant HTTP and content-type handling logic and tests from the mixin package.
  • Add fetch_test.go to exercise successful YAML/text/plain downloads and various error paths (unsupported content type, 404, non-2xx).
internal/fetch/fetch.go
internal/fetch/fetch_test.go
internal/config/config/mixin.go
internal/config/config/mixin_test.go
Wire CLI support for remote configs and expose a --no-cache flag in both parsing and help output.
  • Change cli.Main to detect HTTP(S) config values via isRemoteURL and call loader.LoadRemote or loader.Load accordingly, while switching to explicit config.Config imports and a separate loader alias for the package.
  • Extend the root flags struct and parseRootFlags to handle a --no-cache boolean, thread it into the call to LoadRemote, and register the flag on the Cobra root command with appropriate help text.
  • Add isRemoteURL helper that treats http:// and https:// prefixes as remote configs and ensures help/flag tests are updated via golden files (or regenerated).
internal/cli/cli.go
internal/cmd/root.go
internal/cmd/testdata/TestHelpGolden/basic.golden
internal/cmd/testdata/TestHelpGolden/grouped_commands.golden
internal/cmd/testdata/TestHelpGolden/grouped_commands_long.golden
internal/cmd/testdata/TestHelpGolden/long_command.golden
Document the new remote config capability in the changelog.
  • Update the Unreleased section of the changelog to describe remote config support, cache location, and use of --no-cache for forced re-downloads.
docs/docs/changelog.md

Possibly linked issues

  • #Remote config: PR implements the requested remote config feature: lets -c URL, local caching, and a flag to force re-download.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • Consider threading a context from the CLI into LoadRemote/ensureRemoteConfig and down to fetch.Download instead of always using context.Background(), so long-running or hung remote fetches can be cancelled cleanly on Ctrl-C.
  • In fetch.Download, responses with no or malformed Content-Type headers are currently rejected; if you expect configs to be served by simpler static hosts, it might be worth treating an empty/unknown Content-Type as acceptable YAML instead of hard-failing.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Consider threading a context from the CLI into `LoadRemote`/`ensureRemoteConfig` and down to `fetch.Download` instead of always using `context.Background()`, so long-running or hung remote fetches can be cancelled cleanly on Ctrl-C.
- In `fetch.Download`, responses with no or malformed `Content-Type` headers are currently rejected; if you expect configs to be served by simpler static hosts, it might be worth treating an empty/unknown `Content-Type` as acceptable YAML instead of hard-failing.

## Individual Comments

### Comment 1
<location path="internal/fetch/fetch.go" line_range="41-42" />
<code_context>
-
-	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)
-	}
-
</code_context>
<issue_to_address>
**suggestion:** Include the URL in non-2xx HTTP error messages for better diagnosability

Right now `network error: %s` only includes the status text, which makes it hard to tell which request failed when multiple remote configs/mixins are fetched. Please include the URL (or at least host/path) in this error to make logs and user-facing messages easier to interpret.

```suggestion
	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)
	}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/fetch/fetch.go
Comment on lines +41 to +42
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("no such file at: %s", url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Include the URL in non-2xx HTTP error messages for better diagnosability

Right now network error: %s only includes the status text, which makes it hard to tell which request failed when multiple remote configs/mixins are fetched. Please include the URL (or at least host/path) in this error to make logs and user-facing messages easier to interpret.

Suggested change
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("no such file at: %s", url)
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)
}

kindermax added 12 commits June 14, 2026 12:21
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.
Downloads a remote config by URL, caches it under
~/.config/lets/remote-configs/<sha256(url)>.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.
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant