From 0fbe76f0415547194168c38ce8aa2f1b24be3a9b Mon Sep 17 00:00:00 2001 From: Tim Schindler Date: Thu, 19 Feb 2026 21:24:37 +0100 Subject: [PATCH] feat: add grant update command for self-update via GitHub Releases Uses rhysd/go-github-selfupdate (already an indirect dep via the SDK) to check for newer releases and replace the current binary in-place. Guards against dev builds with an explicit error message. --- CHANGELOG.md | 4 ++ CLAUDE.md | 1 + cmd/commands.go | 1 + cmd/interfaces.go | 7 ++ cmd/test_mocks.go | 16 +++++ cmd/update.go | 57 +++++++++++++++ cmd/update_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 +- 8 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 cmd/update.go create mode 100644 cmd/update_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 615b0ea..e1dccd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- `grant update` command for self-updating the binary via GitHub Releases using `rhysd/go-github-selfupdate` + ## [0.3.0] - 2026-02-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index a30323d..d7d3055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ Custom `SCAAccessService` follows SDK conventions: - `Iilun/survey/v2` for interactive prompts - `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh` - `grant revoke` — revoke sessions: direct (`grant revoke `), `--all`, or interactive multi-select; `--yes` skips confirmation +- `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds - `--groups` flag on root command shows only Entra ID groups in the interactive selector - `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`) - Root command unified selector shows both cloud roles and Entra ID groups; groups use `/eligibility/groups` and `/elevate/groups` API endpoints diff --git a/cmd/commands.go b/cmd/commands.go index 4b77285..83b6b0a 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -10,5 +10,6 @@ func init() { NewFavoritesCommand(), NewEnvCommand(), NewRevokeCommand(), + NewUpdateCommand(), ) } diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 8b9ecd1..8e41b35 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -4,8 +4,10 @@ import ( "context" "github.com/aaearon/grant-cli/internal/sca/models" + "github.com/blang/semver" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" + "github.com/rhysd/go-github-selfupdate/selfupdate" ) // authLoader interface for loading authentication @@ -82,3 +84,8 @@ type groupsElevator interface { type unifiedSelector interface { SelectItem(items []selectionItem) (*selectionItem, error) } + +// selfUpdater interface for self-updating the binary via GitHub Releases +type selfUpdater interface { + UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error) +} diff --git a/cmd/test_mocks.go b/cmd/test_mocks.go index f44710c..93aa336 100644 --- a/cmd/test_mocks.go +++ b/cmd/test_mocks.go @@ -6,8 +6,10 @@ import ( "sync" "github.com/aaearon/grant-cli/internal/sca/models" + "github.com/blang/semver" sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models" authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth" + "github.com/rhysd/go-github-selfupdate/selfupdate" ) // errNotAuthenticated is a sentinel error used in tests to simulate @@ -222,6 +224,20 @@ func (m *mockUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem, return m.item, m.selectErr } +// mockSelfUpdater implements selfUpdater interface for testing +type mockSelfUpdater struct { + updateSelfFn func(semver.Version, string) (*selfupdate.Release, error) + release *selfupdate.Release + updateErr error +} + +func (m *mockSelfUpdater) UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error) { + if m.updateSelfFn != nil { + return m.updateSelfFn(current, slug) + } + return m.release, m.updateErr +} + // countingEligibilityLister wraps an eligibilityLister and counts calls per CSP. // Thread-safe for concurrent access from goroutines in fetchStatusData etc. type countingEligibilityLister struct { diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..a108aac --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" + "github.com/spf13/cobra" +) + +const updateSlug = "aaearon/grant-cli" + +// NewUpdateCommand creates the update command with production dependencies +func NewUpdateCommand() *cobra.Command { + return NewUpdateCommandWithDeps(selfupdate.DefaultUpdater()) +} + +// NewUpdateCommandWithDeps creates the update command with injected dependencies +func NewUpdateCommandWithDeps(updater selfUpdater) *cobra.Command { + return &cobra.Command{ + Use: "update", + Short: "Update grant to the latest version", + Long: "Check GitHub Releases for a newer version of grant and replace the current binary in-place.", + RunE: func(cmd *cobra.Command, args []string) error { + return runUpdate(cmd, updater) + }, + } +} + +func runUpdate(cmd *cobra.Command, updater selfUpdater) error { + v := version + if v == "" || v == "dev" { + return fmt.Errorf("cannot update a dev build; install a release build or download from GitHub Releases") + } + + current, err := semver.Parse(strings.TrimPrefix(v, "v")) + if err != nil { + return fmt.Errorf("failed to parse current version %q: %w", v, err) + } + + rel, err := updater.UpdateSelf(current, updateSlug) + if err != nil { + return fmt.Errorf("update failed: %w", err) + } + if rel == nil { + return fmt.Errorf("update check returned no release information") + } + + if current.Equals(rel.Version) { + fmt.Fprintf(cmd.OutOrStdout(), "grant %s is already up to date.\n", current) + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated grant from %s to %s.\n", current, rel.Version) + return nil +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..ad30e6a --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/blang/semver" + "github.com/rhysd/go-github-selfupdate/selfupdate" +) + +func TestUpdateCommand(t *testing.T) { + tests := []struct { + name string + version string + updater *mockSelfUpdater + wantErr bool + wantContain []string + }{ + { + name: "dev build returns error", + version: "", + updater: &mockSelfUpdater{}, + wantErr: true, + wantContain: []string{ + "cannot update a dev build", + }, + }, + { + name: "explicit dev version returns error", + version: "dev", + updater: &mockSelfUpdater{}, + wantErr: true, + wantContain: []string{ + "cannot update a dev build", + }, + }, + { + name: "already up to date", + version: "1.0.0", + updater: &mockSelfUpdater{ + release: &selfupdate.Release{ + Version: semver.MustParse("1.0.0"), + }, + }, + wantErr: false, + wantContain: []string{ + "already up to date", + "1.0.0", + }, + }, + { + name: "successful update", + version: "1.0.0", + updater: &mockSelfUpdater{ + release: &selfupdate.Release{ + Version: semver.MustParse("1.1.0"), + }, + }, + wantErr: false, + wantContain: []string{ + "1.0.0", + "1.1.0", + }, + }, + { + name: "api error propagated", + version: "1.0.0", + updater: &mockSelfUpdater{ + updateErr: errors.New("rate limit exceeded"), + }, + wantErr: true, + wantContain: []string{ + "update failed", + "rate limit exceeded", + }, + }, + { + name: "nil release without error", + version: "1.0.0", + updater: &mockSelfUpdater{}, + wantErr: true, + wantContain: []string{ + "no release information", + }, + }, + { + name: "version with v prefix", + version: "v2.0.0", + updater: &mockSelfUpdater{ + release: &selfupdate.Release{ + Version: semver.MustParse("2.0.0"), + }, + }, + wantErr: false, + wantContain: []string{ + "already up to date", + "2.0.0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldVersion := version + version = tt.version + defer func() { version = oldVersion }() + + cmd := NewUpdateCommandWithDeps(tt.updater) + output, err := executeCommand(cmd) + + if tt.wantErr && err == nil { + t.Fatal("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for _, want := range tt.wantContain { + if !strings.Contains(output, want) { + t.Errorf("output missing %q\ngot:\n%s", want, output) + } + } + }) + } +} + +func TestUpdateCommandPassesSlug(t *testing.T) { + oldVersion := version + version = "1.0.0" + defer func() { version = oldVersion }() + + var gotSlug string + updater := &mockSelfUpdater{ + updateSelfFn: func(v semver.Version, slug string) (*selfupdate.Release, error) { + gotSlug = slug + return &selfupdate.Release{Version: v}, nil + }, + } + + cmd := NewUpdateCommandWithDeps(updater) + _, err := executeCommand(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotSlug != "aaearon/grant-cli" { + t.Errorf("expected slug %q, got %q", "aaearon/grant-cli", gotSlug) + } +} + +func TestUpdateCommandIntegration(t *testing.T) { + rootCmd := newTestRootCommand() + updateCmd := NewUpdateCommandWithDeps(&mockSelfUpdater{ + release: &selfupdate.Release{ + Version: semver.MustParse("0.0.1"), + }, + }) + rootCmd.AddCommand(updateCmd) + + oldVersion := version + version = "0.0.1" + defer func() { version = oldVersion }() + + output, err := executeCommand(rootCmd, "update") + if err != nil { + t.Fatalf("update command failed: %v", err) + } + + if !strings.Contains(output, "already up to date") { + t.Errorf("expected 'already up to date', got: %s", output) + } +} diff --git a/go.mod b/go.mod index 02b90f5..f82b37d 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,9 @@ go 1.25.0 require ( github.com/Iilun/survey/v2 v2.5.3 + github.com/blang/semver v3.5.1+incompatible github.com/cyberark/idsec-sdk-golang v0.1.14 + github.com/rhysd/go-github-selfupdate v1.2.3 github.com/spf13/cobra v1.9.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,7 +17,6 @@ require ( github.com/EDDYCJY/fake-useragent v0.2.0 // indirect github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect - github.com/blang/semver v3.5.1+incompatible // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect github.com/ebitengine/purego v0.9.0 // indirect @@ -39,7 +40,6 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/rhysd/go-github-selfupdate v1.2.3 // indirect github.com/shirou/gopsutil/v4 v4.25.10 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/tcnksm/go-gitconfig v0.1.2 // indirect