Skip to content

Commit fbd6308

Browse files
authored
Merge pull request #25 from aaearon/feat/update-command
feat: add grant update command
2 parents 324214f + 0fbe76f commit fbd6308

8 files changed

Lines changed: 261 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `grant update` command for self-updating the binary via GitHub Releases using `rhysd/go-github-selfupdate`
10+
711
## [0.3.0] - 2026-02-19
812

913
### Added

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Custom `SCAAccessService` follows SDK conventions:
5656
- `Iilun/survey/v2` for interactive prompts
5757
- `grant env` — performs elevation, outputs only `export` statements (no human text); usage: `eval $(grant env --provider aws)`; supports `--refresh`
5858
- `grant revoke` — revoke sessions: direct (`grant revoke <id>`), `--all`, or interactive multi-select; `--yes` skips confirmation
59+
- `grant update` — self-update binary via GitHub Releases (`rhysd/go-github-selfupdate`); guards against dev builds
5960
- `--groups` flag on root command shows only Entra ID groups in the interactive selector
6061
- `--group` / `-g` flag on root command for direct group membership elevation (`grant --group "Cloud Admins"`)
6162
- Root command unified selector shows both cloud roles and Entra ID groups; groups use `/eligibility/groups` and `/elevate/groups` API endpoints

cmd/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ func init() {
1010
NewFavoritesCommand(),
1111
NewEnvCommand(),
1212
NewRevokeCommand(),
13+
NewUpdateCommand(),
1314
)
1415
}

cmd/interfaces.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55

66
"github.com/aaearon/grant-cli/internal/sca/models"
7+
"github.com/blang/semver"
78
sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models"
89
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
10+
"github.com/rhysd/go-github-selfupdate/selfupdate"
911
)
1012

1113
// authLoader interface for loading authentication
@@ -82,3 +84,8 @@ type groupsElevator interface {
8284
type unifiedSelector interface {
8385
SelectItem(items []selectionItem) (*selectionItem, error)
8486
}
87+
88+
// selfUpdater interface for self-updating the binary via GitHub Releases
89+
type selfUpdater interface {
90+
UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error)
91+
}

cmd/test_mocks.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import (
66
"sync"
77

88
"github.com/aaearon/grant-cli/internal/sca/models"
9+
"github.com/blang/semver"
910
sdkmodels "github.com/cyberark/idsec-sdk-golang/pkg/models"
1011
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
12+
"github.com/rhysd/go-github-selfupdate/selfupdate"
1113
)
1214

1315
// errNotAuthenticated is a sentinel error used in tests to simulate
@@ -222,6 +224,20 @@ func (m *mockUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem,
222224
return m.item, m.selectErr
223225
}
224226

227+
// mockSelfUpdater implements selfUpdater interface for testing
228+
type mockSelfUpdater struct {
229+
updateSelfFn func(semver.Version, string) (*selfupdate.Release, error)
230+
release *selfupdate.Release
231+
updateErr error
232+
}
233+
234+
func (m *mockSelfUpdater) UpdateSelf(current semver.Version, slug string) (*selfupdate.Release, error) {
235+
if m.updateSelfFn != nil {
236+
return m.updateSelfFn(current, slug)
237+
}
238+
return m.release, m.updateErr
239+
}
240+
225241
// countingEligibilityLister wraps an eligibilityLister and counts calls per CSP.
226242
// Thread-safe for concurrent access from goroutines in fetchStatusData etc.
227243
type countingEligibilityLister struct {

cmd/update.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/blang/semver"
8+
"github.com/rhysd/go-github-selfupdate/selfupdate"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
const updateSlug = "aaearon/grant-cli"
13+
14+
// NewUpdateCommand creates the update command with production dependencies
15+
func NewUpdateCommand() *cobra.Command {
16+
return NewUpdateCommandWithDeps(selfupdate.DefaultUpdater())
17+
}
18+
19+
// NewUpdateCommandWithDeps creates the update command with injected dependencies
20+
func NewUpdateCommandWithDeps(updater selfUpdater) *cobra.Command {
21+
return &cobra.Command{
22+
Use: "update",
23+
Short: "Update grant to the latest version",
24+
Long: "Check GitHub Releases for a newer version of grant and replace the current binary in-place.",
25+
RunE: func(cmd *cobra.Command, args []string) error {
26+
return runUpdate(cmd, updater)
27+
},
28+
}
29+
}
30+
31+
func runUpdate(cmd *cobra.Command, updater selfUpdater) error {
32+
v := version
33+
if v == "" || v == "dev" {
34+
return fmt.Errorf("cannot update a dev build; install a release build or download from GitHub Releases")
35+
}
36+
37+
current, err := semver.Parse(strings.TrimPrefix(v, "v"))
38+
if err != nil {
39+
return fmt.Errorf("failed to parse current version %q: %w", v, err)
40+
}
41+
42+
rel, err := updater.UpdateSelf(current, updateSlug)
43+
if err != nil {
44+
return fmt.Errorf("update failed: %w", err)
45+
}
46+
if rel == nil {
47+
return fmt.Errorf("update check returned no release information")
48+
}
49+
50+
if current.Equals(rel.Version) {
51+
fmt.Fprintf(cmd.OutOrStdout(), "grant %s is already up to date.\n", current)
52+
return nil
53+
}
54+
55+
fmt.Fprintf(cmd.OutOrStdout(), "Updated grant from %s to %s.\n", current, rel.Version)
56+
return nil
57+
}

cmd/update_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
8+
"github.com/blang/semver"
9+
"github.com/rhysd/go-github-selfupdate/selfupdate"
10+
)
11+
12+
func TestUpdateCommand(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
version string
16+
updater *mockSelfUpdater
17+
wantErr bool
18+
wantContain []string
19+
}{
20+
{
21+
name: "dev build returns error",
22+
version: "",
23+
updater: &mockSelfUpdater{},
24+
wantErr: true,
25+
wantContain: []string{
26+
"cannot update a dev build",
27+
},
28+
},
29+
{
30+
name: "explicit dev version returns error",
31+
version: "dev",
32+
updater: &mockSelfUpdater{},
33+
wantErr: true,
34+
wantContain: []string{
35+
"cannot update a dev build",
36+
},
37+
},
38+
{
39+
name: "already up to date",
40+
version: "1.0.0",
41+
updater: &mockSelfUpdater{
42+
release: &selfupdate.Release{
43+
Version: semver.MustParse("1.0.0"),
44+
},
45+
},
46+
wantErr: false,
47+
wantContain: []string{
48+
"already up to date",
49+
"1.0.0",
50+
},
51+
},
52+
{
53+
name: "successful update",
54+
version: "1.0.0",
55+
updater: &mockSelfUpdater{
56+
release: &selfupdate.Release{
57+
Version: semver.MustParse("1.1.0"),
58+
},
59+
},
60+
wantErr: false,
61+
wantContain: []string{
62+
"1.0.0",
63+
"1.1.0",
64+
},
65+
},
66+
{
67+
name: "api error propagated",
68+
version: "1.0.0",
69+
updater: &mockSelfUpdater{
70+
updateErr: errors.New("rate limit exceeded"),
71+
},
72+
wantErr: true,
73+
wantContain: []string{
74+
"update failed",
75+
"rate limit exceeded",
76+
},
77+
},
78+
{
79+
name: "nil release without error",
80+
version: "1.0.0",
81+
updater: &mockSelfUpdater{},
82+
wantErr: true,
83+
wantContain: []string{
84+
"no release information",
85+
},
86+
},
87+
{
88+
name: "version with v prefix",
89+
version: "v2.0.0",
90+
updater: &mockSelfUpdater{
91+
release: &selfupdate.Release{
92+
Version: semver.MustParse("2.0.0"),
93+
},
94+
},
95+
wantErr: false,
96+
wantContain: []string{
97+
"already up to date",
98+
"2.0.0",
99+
},
100+
},
101+
}
102+
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
oldVersion := version
106+
version = tt.version
107+
defer func() { version = oldVersion }()
108+
109+
cmd := NewUpdateCommandWithDeps(tt.updater)
110+
output, err := executeCommand(cmd)
111+
112+
if tt.wantErr && err == nil {
113+
t.Fatal("expected error, got nil")
114+
}
115+
if !tt.wantErr && err != nil {
116+
t.Fatalf("unexpected error: %v", err)
117+
}
118+
119+
for _, want := range tt.wantContain {
120+
if !strings.Contains(output, want) {
121+
t.Errorf("output missing %q\ngot:\n%s", want, output)
122+
}
123+
}
124+
})
125+
}
126+
}
127+
128+
func TestUpdateCommandPassesSlug(t *testing.T) {
129+
oldVersion := version
130+
version = "1.0.0"
131+
defer func() { version = oldVersion }()
132+
133+
var gotSlug string
134+
updater := &mockSelfUpdater{
135+
updateSelfFn: func(v semver.Version, slug string) (*selfupdate.Release, error) {
136+
gotSlug = slug
137+
return &selfupdate.Release{Version: v}, nil
138+
},
139+
}
140+
141+
cmd := NewUpdateCommandWithDeps(updater)
142+
_, err := executeCommand(cmd)
143+
if err != nil {
144+
t.Fatalf("unexpected error: %v", err)
145+
}
146+
147+
if gotSlug != "aaearon/grant-cli" {
148+
t.Errorf("expected slug %q, got %q", "aaearon/grant-cli", gotSlug)
149+
}
150+
}
151+
152+
func TestUpdateCommandIntegration(t *testing.T) {
153+
rootCmd := newTestRootCommand()
154+
updateCmd := NewUpdateCommandWithDeps(&mockSelfUpdater{
155+
release: &selfupdate.Release{
156+
Version: semver.MustParse("0.0.1"),
157+
},
158+
})
159+
rootCmd.AddCommand(updateCmd)
160+
161+
oldVersion := version
162+
version = "0.0.1"
163+
defer func() { version = oldVersion }()
164+
165+
output, err := executeCommand(rootCmd, "update")
166+
if err != nil {
167+
t.Fatalf("update command failed: %v", err)
168+
}
169+
170+
if !strings.Contains(output, "already up to date") {
171+
t.Errorf("expected 'already up to date', got: %s", output)
172+
}
173+
}

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ go 1.25.0
44

55
require (
66
github.com/Iilun/survey/v2 v2.5.3
7+
github.com/blang/semver v3.5.1+incompatible
78
github.com/cyberark/idsec-sdk-golang v0.1.14
9+
github.com/rhysd/go-github-selfupdate v1.2.3
810
github.com/spf13/cobra v1.9.1
911
gopkg.in/yaml.v3 v3.0.1
1012
)
@@ -15,7 +17,6 @@ require (
1517
github.com/EDDYCJY/fake-useragent v0.2.0 // indirect
1618
github.com/PuerkitoBio/goquery v1.10.3 // indirect
1719
github.com/andybalholm/cascadia v1.3.3 // indirect
18-
github.com/blang/semver v3.5.1+incompatible // indirect
1920
github.com/danieljoos/wincred v1.2.2 // indirect
2021
github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
2122
github.com/ebitengine/purego v0.9.0 // indirect
@@ -39,7 +40,6 @@ require (
3940
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
4041
github.com/mtibben/percent v0.2.1 // indirect
4142
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
42-
github.com/rhysd/go-github-selfupdate v1.2.3 // indirect
4343
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
4444
github.com/spf13/pflag v1.0.6 // indirect
4545
github.com/tcnksm/go-gitconfig v0.1.2 // indirect

0 commit comments

Comments
 (0)