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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`), `--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
Expand Down
1 change: 1 addition & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ func init() {
NewFavoritesCommand(),
NewEnvCommand(),
NewRevokeCommand(),
NewUpdateCommand(),
)
}
7 changes: 7 additions & 0 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
16 changes: 16 additions & 0 deletions cmd/test_mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions cmd/update.go
Original file line number Diff line number Diff line change
@@ -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
}
173 changes: 173 additions & 0 deletions cmd/update_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Expand All @@ -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
Expand Down