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
5 changes: 5 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,16 @@ linters:
- unconvert # unnecessary type conversions
# --- Testing ---
- usetesting # modern test helpers (t.Context, etc.)
# --- Formatting ---
- gofmt # canonical gofmt formatting (applies to test files too)

linters-settings:
gocognit:
min-complexity: 40

gofmt:
simplify: true

gocritic:
enabled-tags:
- diagnostic
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ All notable changes to this project will be documented in this file.

- Rebranded user-facing references from CyberArk to Idira (formerly CyberArk) in the README and CLI help/prompt text, following the Palo Alto Networks rebrand. No functional or API changes; SDK import paths (`github.com/cyberark/idsec-sdk-golang`), `*.cyberark.cloud` URLs, and environment variables are unchanged.

### Fixed

- `grant configure` no longer reports a stale SDK profile location. The help text and the "Profile saved to" success message printed `~/.idsec_profiles/grant`, which has been wrong since the SDK upgrade in v0.7.0. The path is now resolved with the SDK's own `profiles.GetProfilesFolder()`, so it always matches the directory the profile loader reads (`~/.idsec/profiles/grant` by default, or `IDSEC_PROFILES_FOLDER` when set).

## [0.7.0] - 2026-04-21

### Changed

- **Profile location moved.** As a side effect of the SDK upgrade, the SDK profile directory changed from `~/.idsec_profiles/` to `~/.idsec/profiles/` (override with `IDSEC_PROFILES_FOLDER`). Users upgrading from v0.6.x or earlier must either re-run `grant configure` (recommended) or move the old file into place. If a profile already exists at the new location it is the current one and must win, so use the no-clobber form (`-n` is supported by both GNU and BSD/macOS `mv`): `mkdir -p ~/.idsec/profiles && mv -n ~/.idsec_profiles/grant ~/.idsec/profiles/grant`.
- Upgraded `github.com/cyberark/idsec-sdk-golang` from v0.1.14 to v0.2.3. `isp.FromISPAuth` now takes a retry-strategy argument; we pass `nil` to preserve v0.1.14 behavior (which itself defaulted to nil internally). No new direct Go module dependencies; indirect dep set is smaller
- `--output json` is now a pure serialisation flag; it no longer forces non-interactive mode. Interactive pickers and prompts (e.g. `grant request get -o json` with no ID, `grant request submit -o json` without `--target`/`--role`) work in a TTY, writing prompts to stderr and JSON to stdout.

Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ Custom `SCAAccessService` follows SDK conventions:

## Config
- App config: `~/.grant/config.yaml`
- SDK profile: `~/.idsec_profiles/grant`
- SDK profile: `~/.idsec/profiles/grant` (default; override via `IDSEC_PROFILES_FOLDER`)
- Always resolve the profile directory with `profiles.GetProfilesFolder()` (SDK) — never hand-roll it. The SDK reads `os.Getenv("HOME")`, not `os.UserHomeDir()`; on Windows `HOME` is frequently unset, so it resolves to a **relative** `.idsec/profiles` under the process CWD. Any code that prints or computes the profile path must agree with the loader, so reproduce the SDK's behavior rather than "correcting" it

## Authentication
- Use the `/grant-login` skill when you need to authenticate to the grant CLI (e.g., before manual testing)
Expand All @@ -138,8 +139,9 @@ Custom `SCAAccessService` follows SDK conventions:

## Lint
- Config: `.golangci.yml` (golangci-lint v1 format)
- 19 linters enabled: defaults (errcheck, gosimple, govet, ineffassign, staticcheck, unused) + bodyclose, errorlint, noctx, gosec (G101 excluded), errname, gocritic, misspell, revive, gocognit (threshold 40), perfsprint, unconvert, usetesting
- Test files excluded from gosec, gocognit, bodyclose
- 20 linters enabled: defaults (errcheck, gosimple, govet, ineffassign, staticcheck, unused) + bodyclose, errorlint, noctx, gosec (G101 excluded), errname, gocritic, misspell, revive, gocognit (threshold 40), perfsprint, unconvert, usetesting, gofmt (`simplify: true`)
- Test files excluded from gosec, gocognit, bodyclose — `gofmt` has no exclusion, formatting is universal
- Run `gofmt -s -w .` before committing; `gofumpt` was rejected because the codebase is not gofumpt-clean
- `revive/unused-parameter` and `revive/exported` disabled (Cobra signatures, established API names)
- Use `errors.New` for static error strings (perfsprint enforced); `fmt.Errorf` only with `%` verbs
- Use `t.Context()` instead of `context.Background()` in tests (usetesting enforced)
Expand Down
14 changes: 5 additions & 9 deletions cmd/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@ import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
Comment on lines 4 to 8

survey "github.com/Iilun/survey/v2"
"github.com/aaearon/grant-cli/internal/config"
"github.com/cyberark/idsec-sdk-golang/pkg/models"
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
"github.com/cyberark/idsec-sdk-golang/pkg/profiles"
survey "github.com/Iilun/survey/v2"
"github.com/spf13/cobra"
)

Expand All @@ -24,7 +23,7 @@ func NewConfigureCommand() *cobra.Command {
Long: `Configure grant by providing your Idira username and optional Identity URL.

This command creates two configuration files:
- SDK profile at ~/.idsec_profiles/grant
- SDK profile at ~/.idsec/profiles/grant (override with IDSEC_PROFILES_FOLDER)
- App config at ~/.grant/config.yaml

The Identity URL is optional — the SDK can auto-discover it from your username.
Expand Down Expand Up @@ -110,12 +109,9 @@ func runConfigure(cmd *cobra.Command, saver profileSaver, tenantURL, username st
return fmt.Errorf("failed to save profile: %w", err)
}

// Get profile directory for success message
profileDir := os.Getenv("IDSEC_PROFILES_FOLDER")
if profileDir == "" {
home, _ := os.UserHomeDir()
profileDir = filepath.Join(home, ".idsec_profiles")
}
// Get profile directory for success message. Must use the SDK's own resolver
// so the printed path matches what the profile loader will later read.
profileDir := profiles.GetProfilesFolder()
profilePath := filepath.Join(profileDir, "grant")

// Create app config
Expand Down
132 changes: 132 additions & 0 deletions cmd/configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/aaearon/grant-cli/internal/config"
"github.com/cyberark/idsec-sdk-golang/pkg/models"
authmodels "github.com/cyberark/idsec-sdk-golang/pkg/models/auth"
"github.com/cyberark/idsec-sdk-golang/pkg/profiles"
)

func TestConfigureCommand(t *testing.T) {
Expand Down Expand Up @@ -374,3 +375,134 @@ func TestValidateTenantURL(t *testing.T) {
}
}

// How the IDSEC_PROFILES_FOLDER override is set up for a test case. The SDK
// only honors the override when it is set AND non-empty, so "unset" and
// "empty" must be distinguishable.
const (
folderUnset = "unset"
folderEmpty = "empty"
folderCustom = "custom"
)

// TestRunConfigurePrintsProfilePath verifies the success message reports the
// same directory the SDK profile loader actually uses (profiles.GetProfilesFolder).
//
// These cases deliberately mirror the SDK's own resolution — including its use
// of os.Getenv("HOME") rather than os.UserHomeDir(), which yields a relative
// path when HOME is empty (the common Windows case). configure must print
// whatever the loader will later read, warts included.
//
// Not parallel: t.Setenv mutates process-global state.
func TestRunConfigurePrintsProfilePath(t *testing.T) {
tests := []struct {
name string
homeTempDir bool // use t.TempDir() as HOME
home string // literal HOME value when homeTempDir is false
folderMode string
wantContainsFn func(profilesFolder, home string) string
}{
{
name: "honors IDSEC_PROFILES_FOLDER",
homeTempDir: true,
folderMode: folderCustom,
wantContainsFn: func(profilesFolder, home string) string {
return filepath.Join(profilesFolder, "grant")
},
},
{
name: "default under HOME when override unset",
homeTempDir: true,
folderMode: folderUnset,
wantContainsFn: func(profilesFolder, home string) string {
return filepath.Join(home, ".idsec", "profiles", "grant")
},
},
{
name: "empty IDSEC_PROFILES_FOLDER falls back to HOME",
homeTempDir: true,
folderMode: folderEmpty,
wantContainsFn: func(profilesFolder, home string) string {
return filepath.Join(home, ".idsec", "profiles", "grant")
},
},
{
name: "empty HOME yields a relative path",
home: "",
folderMode: folderUnset,
wantContainsFn: func(profilesFolder, home string) string {
return filepath.Join(".idsec", "profiles", "grant")
},
},
{
name: "relative HOME is preserved",
home: filepath.Join("relative", "home"),
folderMode: folderUnset,
wantContainsFn: func(profilesFolder, home string) string {
return filepath.Join(home, ".idsec", "profiles", "grant")
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := tt.home
if tt.homeTempDir {
home = t.TempDir()
}
t.Setenv("HOME", home)

// t.Setenv always restores the original value on cleanup, so the
// unset case is safe to express as Setenv followed by Unsetenv.
t.Setenv("IDSEC_PROFILES_FOLDER", "")
profilesFolder := ""
switch tt.folderMode {
case folderCustom:
profilesFolder = t.TempDir()
t.Setenv("IDSEC_PROFILES_FOLDER", profilesFolder)
case folderEmpty:
// already set to "" above
case folderUnset:
if err := os.Unsetenv("IDSEC_PROFILES_FOLDER"); err != nil {
t.Fatalf("failed to unset IDSEC_PROFILES_FOLDER: %v", err)
}
default:
t.Fatalf("unknown folderMode %q", tt.folderMode)
}

cfgPath := filepath.Join(t.TempDir(), "config.yaml")
t.Setenv("GRANT_CONFIG", cfgPath)

saver := &mockProfileSaver{
saveFunc: func(p *models.IdsecProfile) error { return nil },
}
cmd := NewConfigureCommandWithDeps(saver, "https://example.cyberark.cloud", "test.user@example.com")

output, err := executeCommand(cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// The printed path must equal what the SDK loader resolves, so the
// path configure reports is the path login will later read.
wantDir := profiles.GetProfilesFolder()
if !strings.Contains(output, filepath.Join(wantDir, "grant")) {
t.Errorf("output does not match profiles.GetProfilesFolder() %q\ngot:\n%s", wantDir, output)
}

want := tt.wantContainsFn(profilesFolder, home)
if !strings.Contains(output, want) {
t.Errorf("output missing %q\ngot:\n%s", want, output)
}
if strings.Contains(output, ".idsec_profiles") {
t.Errorf("output contains legacy path %q\ngot:\n%s", ".idsec_profiles", output)
}
})
}
}

func TestConfigureLongHelpHasNoLegacyPath(t *testing.T) {
long := NewConfigureCommand().Long
if strings.Contains(long, ".idsec_profiles") {
t.Errorf("configure Long help still mentions legacy path .idsec_profiles:\n%s", long)
}
}
4 changes: 2 additions & 2 deletions cmd/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ func runEnvWithDeps(

if isJSONOutput() {
return writeJSON(cmd.OutOrStdout(), awsCredentialOutput{
AccessKeyID: awsCreds.AccessKeyID,
AccessKeyID: awsCreds.AccessKeyID,
SecretAccessKey: awsCreds.SecretAccessKey,
SessionToken: awsCreds.SessionToken,
SessionToken: awsCreds.SessionToken,
})
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/favorites_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -814,9 +814,9 @@ func TestFavoritesAddInteractiveMode(t *testing.T) {
cfg := config.DefaultConfig()
_ = config.Save(cfg, path)
},
eligLister: nil,
selector: nil,
args: []string{"--target", "sub-123", "--role", "Contributor"},
eligLister: nil,
selector: nil,
args: []string{"--target", "sub-123", "--role", "Contributor"},
wantContain: []string{"name is required"},
wantErr: true,
},
Expand Down
4 changes: 2 additions & 2 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import (
type statusData struct {
sessions *scamodels.SessionsResponse
nameMap map[string]string
groupNameMap map[string]string // groupID -> groupName
remainingMap map[string]time.Duration // sessionID -> remaining time
groupNameMap map[string]string // groupID -> groupName
remainingMap map[string]time.Duration // sessionID -> remaining time
}

// fetchStatusData fires sessions and all-CSP eligibility calls concurrently,
Expand Down
4 changes: 2 additions & 2 deletions cmd/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,8 @@ func TestIntegration_InvalidCommand(t *testing.T) {

// Should show an error or help message
if !strings.Contains(outputStr, "unknown command") &&
!strings.Contains(outputStr, "Error:") &&
!strings.Contains(outputStr, "Usage:") {
!strings.Contains(outputStr, "Error:") &&
!strings.Contains(outputStr, "Usage:") {
t.Errorf("Expected error for invalid command, got:\n%s", outputStr)
}
}
1 change: 0 additions & 1 deletion cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,3 @@ type accessRequestService interface {
CancelRequest(ctx context.Context, requestID string, reason *string) (*wfmodels.AccessRequest, error)
FinalizeRequest(ctx context.Context, requestID string, result string, reason *string) (*wfmodels.AccessRequest, error)
}

10 changes: 5 additions & 5 deletions cmd/output_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ package cmd

// cloudElevationOutput is the JSON representation of a cloud elevation result.
type cloudElevationOutput struct {
Type string `json:"type"`
Provider string `json:"provider"`
SessionID string `json:"sessionId"`
Target string `json:"target"`
Role string `json:"role"`
Type string `json:"type"`
Provider string `json:"provider"`
SessionID string `json:"sessionId"`
Target string `json:"target"`
Role string `json:"role"`
Credentials *awsCredentialOutput `json:"credentials,omitempty"`
}

Expand Down
8 changes: 4 additions & 4 deletions cmd/request_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ type submitFields struct {

// submitWorkspace holds deduplicated workspace info derived from eligibility.
type submitWorkspace struct {
WorkspaceID string
WorkspaceName string
WorkspaceType models.WorkspaceType
CSP models.CSP
WorkspaceID string
WorkspaceName string
WorkspaceType models.WorkspaceType
CSP models.CSP
OrganizationID string
}

Expand Down
Loading
Loading