Skip to content

Commit f2c8362

Browse files
committed
feat: add TTY detection and fail-fast for non-interactive mode
Introduce internal/ui/tty.go with IsInteractive() and ErrNotInteractive so all interactive prompts fail fast with helpful error messages when stdin is not a terminal, preventing hangs in LLM/CI/pipe contexts. Guarded functions: SelectTarget, SelectSessions, ConfirmRevocation, SelectGroup, uiUnifiedSelector.SelectItem, surveyNamePrompter.PromptName. Promoted go-isatty v0.0.20 from indirect to direct dependency.
1 parent 8ca719e commit f2c8362

File tree

14 files changed

+220
-1
lines changed

14 files changed

+220
-1
lines changed

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ Custom `SCAAccessService` follows SDK conventions:
6464
- `--refresh` bypasses eligibility cache on `grant` and `grant env`
6565
- `fetchEligibility()` and `resolveTargetCSP()` in `cmd/root.go` — shared by root, env, and favorites
6666

67+
## TTY Detection
68+
- `internal/ui/tty.go``IsTerminalFunc` (overridable), `IsInteractive()`, `ErrNotInteractive`
69+
- All interactive prompts (`SelectTarget`, `SelectSessions`, `ConfirmRevocation`, `SelectGroup`, `uiUnifiedSelector.SelectItem`, `surveyNamePrompter.PromptName`) fail fast with `ErrNotInteractive` when stdin is not a TTY
70+
- Error messages suggest the appropriate non-interactive flag (e.g., `--target/--role`, `--all`, `--yes`, `--group`, `--favorite`)
71+
- `go-isatty` v0.0.20 is a direct dependency (promoted from indirect via survey)
72+
6773
## Cache
6874
- Eligibility responses cached in `~/.grant/cache/` as JSON files (e.g., `eligibility_azure.json`, `groups_eligibility_azure.json`)
6975
- Default TTL: 4 hours, configurable via `cache_ttl` in `~/.grant/config.yaml` (Go duration syntax: `2h`, `30m`)

cmd/favorites.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
survey "github.com/Iilun/survey/v2"
1010
"github.com/aaearon/grant-cli/internal/config"
11+
"github.com/aaearon/grant-cli/internal/ui"
1112
"github.com/spf13/cobra"
1213
)
1314

@@ -95,6 +96,10 @@ func newFavoritesAddCommand() *cobra.Command {
9596
type surveyNamePrompter struct{}
9697

9798
func (s *surveyNamePrompter) PromptName() (string, error) {
99+
if !ui.IsInteractive() {
100+
return "", fmt.Errorf("%w; provide the name as an argument", ui.ErrNotInteractive)
101+
}
102+
98103
var name string
99104
if err := survey.AskOne(&survey.Input{
100105
Message: "Favorite name:",

cmd/favorites_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/aaearon/grant-cli/internal/cache"
1212
"github.com/aaearon/grant-cli/internal/config"
1313
"github.com/aaearon/grant-cli/internal/sca/models"
14+
"github.com/aaearon/grant-cli/internal/ui"
1415
)
1516

1617
func TestFavoritesListCommand(t *testing.T) {
@@ -1183,3 +1184,21 @@ func TestFavoritesListWithGroupFavorites(t *testing.T) {
11831184
})
11841185
}
11851186
}
1187+
1188+
func TestSurveyNamePrompter_NonTTY(t *testing.T) {
1189+
original := ui.IsTerminalFunc
1190+
defer func() { ui.IsTerminalFunc = original }()
1191+
ui.IsTerminalFunc = func(fd uintptr) bool { return false }
1192+
1193+
prompter := &surveyNamePrompter{}
1194+
_, err := prompter.PromptName()
1195+
if err == nil {
1196+
t.Fatal("expected error for non-TTY")
1197+
}
1198+
if !errors.Is(err, ui.ErrNotInteractive) {
1199+
t.Errorf("expected ErrNotInteractive, got: %v", err)
1200+
}
1201+
if !strings.Contains(err.Error(), "argument") {
1202+
t.Errorf("error should suggest providing name as argument, got: %v", err)
1203+
}
1204+
}

cmd/root.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,10 @@ func (s *uiSelector) SelectTarget(targets []models.EligibleTarget) (*models.Elig
830830
type uiUnifiedSelector struct{}
831831

832832
func (s *uiUnifiedSelector) SelectItem(items []selectionItem) (*selectionItem, error) {
833+
if !ui.IsInteractive() {
834+
return nil, fmt.Errorf("%w; use --target/--role, --group, or --favorite flags for non-interactive mode", ui.ErrNotInteractive)
835+
}
836+
833837
if len(items) == 0 {
834838
return nil, errors.New("no eligible targets or groups available")
835839
}

cmd/root_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"strings"
77
"testing"
88

9+
"github.com/aaearon/grant-cli/internal/sca/models"
10+
"github.com/aaearon/grant-cli/internal/ui"
911
"github.com/cyberark/idsec-sdk-golang/pkg/config"
1012
"github.com/spf13/cobra"
1113
)
@@ -219,3 +221,30 @@ func TestExecuteHintOutput(t *testing.T) {
219221
})
220222
}
221223
}
224+
225+
func TestUnifiedSelector_NonTTY(t *testing.T) {
226+
original := ui.IsTerminalFunc
227+
defer func() { ui.IsTerminalFunc = original }()
228+
ui.IsTerminalFunc = func(fd uintptr) bool { return false }
229+
230+
selector := &uiUnifiedSelector{}
231+
items := []selectionItem{
232+
{kind: selectionCloud, cloud: &models.EligibleTarget{
233+
WorkspaceName: "Sub A",
234+
WorkspaceType: models.WorkspaceTypeSubscription,
235+
RoleInfo: models.RoleInfo{Name: "Owner"},
236+
}},
237+
}
238+
239+
_, err := selector.SelectItem(items)
240+
if err == nil {
241+
t.Fatal("expected error for non-TTY")
242+
}
243+
if !errors.Is(err, ui.ErrNotInteractive) {
244+
t.Errorf("expected ErrNotInteractive, got: %v", err)
245+
}
246+
errMsg := err.Error()
247+
if !strings.Contains(errMsg, "--target") || !strings.Contains(errMsg, "--group") || !strings.Contains(errMsg, "--favorite") {
248+
t.Errorf("error should mention --target/--role, --group, and --favorite, got: %v", err)
249+
}
250+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/Iilun/survey/v2 v2.5.3
77
github.com/blang/semver v3.5.1+incompatible
88
github.com/cyberark/idsec-sdk-golang v0.1.14
9+
github.com/mattn/go-isatty v0.0.20
910
github.com/rhysd/go-github-selfupdate v1.2.3
1011
github.com/spf13/cobra v1.9.1
1112
gopkg.in/yaml.v3 v3.0.1
@@ -36,7 +37,6 @@ require (
3637
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
3738
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
3839
github.com/mattn/go-colorable v0.1.14 // indirect
39-
github.com/mattn/go-isatty v0.0.20 // indirect
4040
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
4141
github.com/mtibben/percent v0.2.1 // indirect
4242
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect

internal/ui/group_selector.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ func FindGroupByDisplay(groups []models.GroupsEligibleTarget, display string) (*
4747
// It sorts a copy of the groups so that FindGroupByDisplay searches the same
4848
// ordered slice the user saw, avoiding wrong-group selection on display collisions.
4949
func SelectGroup(groups []models.GroupsEligibleTarget) (*models.GroupsEligibleTarget, error) {
50+
if !IsInteractive() {
51+
return nil, fmt.Errorf("%w; use --group flag for non-interactive mode", ErrNotInteractive)
52+
}
53+
5054
if len(groups) == 0 {
5155
return nil, errors.New("no eligible groups available")
5256
}

internal/ui/group_selector_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ui
22

33
import (
4+
"errors"
5+
"strings"
46
"testing"
57

68
"github.com/aaearon/grant-cli/internal/sca/models"
@@ -196,3 +198,25 @@ func TestFindGroupByDisplay(t *testing.T) {
196198
})
197199
}
198200
}
201+
202+
func TestSelectGroup_NonTTY(t *testing.T) {
203+
t.Parallel()
204+
original := IsTerminalFunc
205+
defer func() { IsTerminalFunc = original }()
206+
IsTerminalFunc = func(fd uintptr) bool { return false }
207+
208+
groups := []models.GroupsEligibleTarget{
209+
{DirectoryID: "dir1", GroupID: "grp1", GroupName: "Engineering"},
210+
}
211+
212+
_, err := SelectGroup(groups)
213+
if err == nil {
214+
t.Fatal("expected error for non-TTY")
215+
}
216+
if !errors.Is(err, ErrNotInteractive) {
217+
t.Errorf("expected ErrNotInteractive, got: %v", err)
218+
}
219+
if !strings.Contains(err.Error(), "--group") {
220+
t.Errorf("error should mention --group, got: %v", err)
221+
}
222+
}

internal/ui/selector.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ func FindTargetByDisplay(targets []models.EligibleTarget, display string) (*mode
6565

6666
// SelectTarget presents an interactive selector for choosing a target.
6767
func SelectTarget(targets []models.EligibleTarget) (*models.EligibleTarget, error) {
68+
if !IsInteractive() {
69+
return nil, fmt.Errorf("%w; use --target and --role flags for non-interactive mode", ErrNotInteractive)
70+
}
71+
6872
if len(targets) == 0 {
6973
return nil, errors.New("no eligible targets available")
7074
}

internal/ui/selector_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ui
22

33
import (
4+
"errors"
5+
"strings"
46
"testing"
57

68
"github.com/aaearon/grant-cli/internal/sca/models"
@@ -185,6 +187,28 @@ func TestBuildOptions(t *testing.T) {
185187
}
186188
}
187189

190+
func TestSelectTarget_NonTTY(t *testing.T) {
191+
t.Parallel()
192+
original := IsTerminalFunc
193+
defer func() { IsTerminalFunc = original }()
194+
IsTerminalFunc = func(fd uintptr) bool { return false }
195+
196+
targets := []models.EligibleTarget{
197+
{WorkspaceName: "Sub A", WorkspaceType: models.WorkspaceTypeSubscription, RoleInfo: models.RoleInfo{Name: "Owner"}},
198+
}
199+
200+
_, err := SelectTarget(targets)
201+
if err == nil {
202+
t.Fatal("expected error for non-TTY")
203+
}
204+
if !errors.Is(err, ErrNotInteractive) {
205+
t.Errorf("expected ErrNotInteractive, got: %v", err)
206+
}
207+
if !strings.Contains(err.Error(), "--target") {
208+
t.Errorf("error should mention --target, got: %v", err)
209+
}
210+
}
211+
188212
func TestFindTargetByDisplay(t *testing.T) {
189213
t.Parallel()
190214
targets := []models.EligibleTarget{

0 commit comments

Comments
 (0)