Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ title: Changelog
## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X)

* `[Added]` Add `checksum.files`, `checksum.sh`, and `checksum.persist` command checksum syntax while keeping the old checksum format compatible.
* `[Added]` Add `lets self upgrade --pre` to opt into upgrading to the latest prerelease.
* `[Added]` Add `lets self fix` config migration command with `--dry-run` preview output for deprecated checksum syntax.
* `[Fixed]` Make checksum calculation respect command-level `work_dir` overrides.
* `[Fixed]` Restore the release checkout after the GoReleaser dry run so prerelease publishing does not fail on a dirty `go.mod`.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ title: CLI options
|`-h, --help`|||help for lets|
|`-v, --version`|||version for lets|

Upgrade the lets binary with `lets self upgrade`.
Upgrade the lets binary with `lets self upgrade`. Use `lets self upgrade --pre` to opt into the latest prerelease.

Migrate deprecated config syntax with `lets self fix`. Use `lets self fix --dry-run` to print migrated config content before writing files.
6 changes: 6 additions & 0 deletions docs/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ It updates the binary located at `which lets`.
lets self upgrade
```

To opt into the latest prerelease:

```bash
lets self upgrade --pre
```

Self upgrade is intended for installer-managed and manual user-owned installs. If `lets` was installed by Homebrew,
Arch, or another package manager, use that package manager instead.

Expand Down
36 changes: 36 additions & 0 deletions internal/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,42 @@ func TestSelfCmd(t *testing.T) {
}
})

t.Run("should pass pre flag to self upgrade factory", func(t *testing.T) {
bufOut := new(bytes.Buffer)
gotPre := false

rootCmd := CreateRootCommand("v0.0.0-test", "")
rootCmd.SetArgs([]string{"self", "upgrade", "--pre"})
rootCmd.SetOut(bufOut)
rootCmd.SetErr(bufOut)
selfCmd := &cobra.Command{
Use: "self",
Short: "Manage lets CLI itself",
}
rootCmd.AddCommand(selfCmd)

selfCmd.AddCommand(initUpgradeCommandWith(func(cmd *cobra.Command) (upgrade.Upgrader, error) {
var err error
gotPre, err = cmd.Flags().GetBool("pre")
if err != nil {
return nil, err
}

return mockUpgraderFunc(func(ctx context.Context) error {
return nil
}), nil
}))

err := rootCmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !gotPre {
t.Fatal("expected pre flag to be set")
}
})

t.Run("should return upgrader error for self upgrade command", func(t *testing.T) {
bufOut := new(bytes.Buffer)

Expand Down
13 changes: 12 additions & 1 deletion internal/cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,18 @@ type upgraderFactory func(cmd *cobra.Command) (upgrade.Upgrader, error)
func initUpgradeCommand(version string, appSettings settings.Settings) *cobra.Command {
return initUpgradeCommandWith(func(cmd *cobra.Command) (upgrade.Upgrader, error) {
progress := upgradeProgress(cmd.ErrOrStderr(), appSettings)
options := []upgrade.BinaryUpgraderOption{upgrade.WithProgress(progress)}

return upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(), version, upgrade.WithProgress(progress))
pre, err := cmd.Flags().GetBool("pre")
if err != nil {
return nil, err
}

if pre {
options = append(options, upgrade.WithPrerelease())
}

return upgrade.NewBinaryUpgrader(registry.NewGithubRegistry(), version, options...)
})
}

Expand Down Expand Up @@ -55,6 +65,7 @@ func initUpgradeCommandWith(createUpgrader upgraderFactory) *cobra.Command {
return nil
},
}
upgradeCmd.Flags().Bool("pre", false, "upgrade to latest prerelease version")

return upgradeCmd
}
4 changes: 4 additions & 0 deletions internal/upgrade/notifier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ func (m *mockNotifierRegistry) GetLatestRelease(ctx context.Context) (string, er
return m.release.TagName, nil
}

func (m *mockNotifierRegistry) GetLatestPrerelease(ctx context.Context) (string, error) {
return "", nil
}

func (m *mockNotifierRegistry) DownloadReleaseBinary(
ctx context.Context,
packageName string,
Expand Down
73 changes: 73 additions & 0 deletions internal/upgrade/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package registry
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -28,6 +29,7 @@ var osMap = map[string]string{
type RepoRegistry interface {
GetLatestReleaseInfo(ctx context.Context) (*ReleaseInfo, error)
GetLatestRelease(ctx context.Context) (string, error)
GetLatestPrerelease(ctx context.Context) (string, error)
DownloadReleaseBinary(
ctx context.Context,
packageName string,
Expand Down Expand Up @@ -179,6 +181,8 @@ func (r progressReadCloser) Read(p []byte) (int, error) {
type ReleaseInfo struct {
TagName string `json:"tag_name"`
PublishedAt time.Time `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
}

func (reg *GithubRegistry) GetLatestRelease(ctx context.Context) (string, error) {
Expand Down Expand Up @@ -232,3 +236,72 @@ func (reg *GithubRegistry) GetLatestReleaseInfo(ctx context.Context) (*ReleaseIn

return &release, nil
}

func (reg *GithubRegistry) GetLatestPrerelease(ctx context.Context) (string, error) {
release, err := reg.GetLatestPrereleaseInfo(ctx)
if err != nil {
return "", err
}

return release.TagName, nil
}

func (reg *GithubRegistry) GetLatestPrereleaseInfo(ctx context.Context) (*ReleaseInfo, error) {
requestCtx, cancel := context.WithTimeout(ctx, reg.latestReleaseTimeout)
defer cancel()

url := reg.apiURI + "/releases?per_page=100"

req, err := http.NewRequestWithContext(
requestCtx,
http.MethodGet,
url,
nil,
)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
Comment on lines +253 to +262

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue: Hard-coded per_page=100 may miss the actual latest prerelease if there are many releases

Using GitHub’s max per_page=100 is fine, but if a repo has >100 releases, you may miss a newer prerelease on later pages and pick an older one. If this is plausible for your use case, either document that only the first 100 releases are considered or add basic pagination (e.g., follow Link headers up to a sane page limit).

}

req.Header.Add("Accept", "application/vnd.github+json")
req.Header.Add("User-Agent", "lets-cli")

resp, err := reg.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request: %w", err)
}

defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, fmt.Errorf("failed to fetch releases: %s", resp.Status)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read package body: %w", err)
}

var releases []ReleaseInfo
if err := json.Unmarshal(body, &releases); err != nil {
return nil, fmt.Errorf("failed to decode package body: %w", err)
}

var latestPrerelease *ReleaseInfo

for i := range releases {
release := &releases[i]
if release.Draft || !release.Prerelease {
continue
}

if latestPrerelease == nil || release.PublishedAt.After(latestPrerelease.PublishedAt) {
latestPrerelease = release
}
}

if latestPrerelease == nil {
return nil, errors.New("no prerelease found")
}

return latestPrerelease, nil
}
53 changes: 53 additions & 0 deletions internal/upgrade/registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,59 @@ func TestGithubRegistryGetLatestRelease(t *testing.T) {
}
}

func TestGithubRegistryGetLatestPrerelease(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Path; got != "/releases" {
t.Fatalf("unexpected path %q", got)
}
if got := r.URL.Query().Get("per_page"); got != "100" {
t.Fatalf("unexpected per_page query %q", got)
}
if got := r.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Fatalf("unexpected accept header %q", got)
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"tag_name":"v0.0.62","published_at":"2026-03-17T10:00:00Z","prerelease":false},
{"tag_name":"v0.0.63-rc1","published_at":"2026-03-18T10:00:00Z","prerelease":true},
{"tag_name":"v0.0.64-rc1","published_at":"2026-03-19T10:00:00Z","prerelease":true,"draft":true},
{"tag_name":"v0.0.63-rc2","published_at":"2026-03-20T10:00:00Z","prerelease":true}
]`))
}))
defer server.Close()

reg := NewGithubRegistry()
reg.apiURI = server.URL

version, err := reg.GetLatestPrerelease(context.Background())
if err != nil {
t.Fatalf("GetLatestPrerelease() error = %v", err)
}
if version != "v0.0.63-rc2" {
t.Fatalf("expected version v0.0.63-rc2, got %q", version)
}
}

func TestGithubRegistryGetLatestPrereleaseNoMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"tag_name":"v0.0.62","published_at":"2026-03-17T10:00:00Z","prerelease":false},
{"tag_name":"v0.0.63-rc1","published_at":"2026-03-18T10:00:00Z","prerelease":true,"draft":true}
]`))
}))
defer server.Close()

reg := NewGithubRegistry()
reg.apiURI = server.URL

_, err := reg.GetLatestPrerelease(context.Background())
if err == nil {
t.Fatal("expected no prerelease error")
}
}

func TestGithubRegistryDownloadReleaseBinaryReportsProgress(t *testing.T) {
archive := releaseArchive(t, "updated binary")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
25 changes: 22 additions & 3 deletions internal/upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Upgrader interface {
type BinaryUpgrader struct {
registry registry.RepoRegistry
currentVersion string
prerelease bool
binaryPath string
downloadPath string
backupPath string
Expand All @@ -37,6 +38,12 @@ func WithProgress(progress fetch.ProgressObserver) BinaryUpgraderOption {
}
}

func WithPrerelease() BinaryUpgraderOption {
return func(upgrader *BinaryUpgrader) {
upgrader.prerelease = true
}
}

func NewBinaryUpgrader(reg registry.RepoRegistry, currentVersion string, options ...BinaryUpgraderOption) (*BinaryUpgrader, error) {
executablePath, err := binaryPath()
if err != nil {
Expand Down Expand Up @@ -66,9 +73,9 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error {
return err
}

latestVersion, err := up.registry.GetLatestRelease(ctx)
latestVersion, releaseKind, err := up.latestVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get latest release version: %w", err)
return fmt.Errorf("failed to get %s version: %w", releaseKind, err)
}

if up.currentVersion == latestVersion {
Expand All @@ -82,7 +89,7 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error {
return fmt.Errorf("failed to get package name: %w", err)
}

log.Printf("Downloading latest release %s...", latestVersion)
log.Printf("Downloading %s %s...", releaseKind, latestVersion)

err = up.registry.DownloadReleaseBinary(
ctx,
Expand Down Expand Up @@ -110,6 +117,18 @@ func (up *BinaryUpgrader) Upgrade(ctx context.Context) error {
return nil
}

func (up *BinaryUpgrader) latestVersion(ctx context.Context) (string, string, error) {
if up.prerelease {
version, err := up.registry.GetLatestPrerelease(ctx)

return version, "latest prerelease", err
}

version, err := up.registry.GetLatestRelease(ctx)

return version, "latest release", err
}

func binaryPath() (string, error) {
return os.Executable()
}
Expand Down
29 changes: 27 additions & 2 deletions internal/upgrade/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (
)

type MockRegistry struct {
latestVersion string
latestVersion string
prereleaseVersion string
}

func (m MockRegistry) GetLatestRelease(ctx context.Context) (string, error) {
Expand All @@ -25,6 +26,10 @@ func (m MockRegistry) GetLatestReleaseInfo(ctx context.Context) (*registry.Relea
return &registry.ReleaseInfo{TagName: m.latestVersion}, nil
}

func (m MockRegistry) GetLatestPrerelease(ctx context.Context) (string, error) {
return m.prereleaseVersion, nil
}

func (m MockRegistry) DownloadReleaseBinary(
ctx context.Context,
packageName string,
Expand All @@ -37,7 +42,7 @@ func (m MockRegistry) DownloadReleaseBinary(
return err
}

latest, _ := m.GetLatestRelease(ctx)
latest := version

_, err = fmt.Fprint(file, latest)
if err != nil {
Expand Down Expand Up @@ -153,6 +158,26 @@ func TestSelfUpgrade(t *testing.T) {
}
})

t.Run("should self-upgrade to latest prerelease version", func(t *testing.T) {
currentVersion := "v0.0.1"
prereleaseVersion := "v0.0.2-rc1"

upgrader, err := newMockUpgrader(&MockRegistry{prereleaseVersion: prereleaseVersion}, currentVersion)
if err != nil {
t.Errorf("failed to create upgrader: %s", err)
}
WithPrerelease()(upgrader)

err = upgrader.Upgrade(context.Background())
if err != nil {
t.Errorf("failed to upgrade: %s", err)
}

if !testVersion(upgrader.binaryPath, prereleaseVersion) {
t.Errorf("expected version %s", prereleaseVersion)
}
})

t.Run("should self-upgrade symlink target", func(t *testing.T) {
currentVersion := "v0.0.1"
latestVersion := "v0.0.2"
Expand Down
Loading