Skip to content

Commit 6864258

Browse files
authored
Merge pull request #27 from aaearon/feat/verbose-logging-all-commands
feat: add verbose logging to all commands
2 parents a616c18 + 0eb7471 commit 6864258

21 files changed

+453
-52
lines changed

CHANGELOG.md

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

55
## [Unreleased]
66

7+
### Added
8+
9+
- Verbose logging (`--verbose`/`-v`) now produces output for all commands, not just those using `SCAAccessService`
10+
- Commands `update`, `version`, `login`, `logout`, `configure`, and `favorites` now emit SDK-format verbose logs (`grant | timestamp | INFO | message`)
11+
12+
### Changed
13+
14+
- Migrated 4 ad-hoc `[verbose]`/`Warning:` messages in `fetchStatusData`, `buildDirectoryNameMap`, `buildWorkspaceNameMap`, and `fetchEligibility` to use the SDK logger for consistent format
15+
- Removed `errWriter io.Writer` parameter from `fetchStatusData`, `buildDirectoryNameMap`, `buildWorkspaceNameMap`, and `fetchGroupsEligibility` (verbose output now goes through SDK logger, not injected writer)
16+
717
## [0.4.0] - 2026-02-19
818

919
### Added

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ Custom `SCAAccessService` follows SDK conventions:
7777
## Verbose / Logging
7878
- `--verbose` / `-v` global flag wired via `PersistentPreRunE` in `cmd/root.go`
7979
- Calls `config.EnableVerboseLogging("INFO")` (sets `IDSEC_LOG_LEVEL=INFO`) or `config.DisableVerboseLogging()` (sets `IDSEC_LOG_LEVEL=CRITICAL`)
80+
- `cmdLogger` interface in `cmd/verbose.go``Info(msg string, v ...interface{})`, satisfied by `*common.IdsecLogger`
81+
- `log` package-level var in `cmd/verbose.go` — all commands use `log.Info(...)` for verbose output; tests swap with `spyLogger`
8082
- `loggingClient` in `internal/sca/logging_client.go` decorates `httpClient`, logging method/route/status/duration at INFO, response headers at DEBUG with Authorization redaction
8183
- `NewSCAAccessService()` wraps ISP client with `loggingClient` using `common.GetLogger("grant", -1)` (dynamic level from env)
8284
- `NewSCAAccessServiceWithClient()` (test constructor) does not wrap — tests don't need logging

cmd/configure.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func runConfigure(cmd *cobra.Command, saver profileSaver, tenantURL, username st
104104
}
105105

106106
// Save SDK profile
107+
log.Info("Saving profile...")
107108
if err := saver.SaveProfile(profile); err != nil {
108109
return fmt.Errorf("failed to save profile: %w", err)
109110
}
@@ -124,6 +125,7 @@ func runConfigure(cmd *cobra.Command, saver profileSaver, tenantURL, username st
124125
}
125126

126127
// Save app config
128+
log.Info("Saving config...")
127129
cfgPath, err := config.ConfigPath()
128130
if err != nil {
129131
return err

cmd/configure_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,41 @@ func TestConfigureCommand(t *testing.T) {
248248
}
249249
}
250250

251+
func TestConfigureCommand_VerboseLogs(t *testing.T) {
252+
spy := &spyLogger{}
253+
oldLog := log
254+
log = spy
255+
defer func() { log = oldLog }()
256+
257+
tmpDir := t.TempDir()
258+
cfgPath := filepath.Join(tmpDir, "config.yaml")
259+
t.Setenv("GRANT_CONFIG", cfgPath)
260+
261+
cmd := NewConfigureCommandWithDeps(
262+
&mockProfileSaver{},
263+
"https://example.cyberark.cloud",
264+
"test.user@example.com",
265+
)
266+
_, err := executeCommand(cmd)
267+
if err != nil {
268+
t.Fatalf("unexpected error: %v", err)
269+
}
270+
271+
wantMessages := []string{"Saving profile", "Saving config"}
272+
for _, want := range wantMessages {
273+
found := false
274+
for _, msg := range spy.messages {
275+
if strings.Contains(msg, want) {
276+
found = true
277+
break
278+
}
279+
}
280+
if !found {
281+
t.Errorf("expected log containing %q, got: %v", want, spy.messages)
282+
}
283+
}
284+
}
285+
251286
func TestConfigureCommandIntegration(t *testing.T) {
252287
// Test that configure command is properly registered
253288
rootCmd := newTestRootCommand()

cmd/favorites.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
188188

189189
// Non-interactive mode requires name upfront
190190
isNonInteractive := (target != "" && role != "") || (favType == config.FavoriteTypeGroups && group != "")
191+
if isNonInteractive {
192+
log.Info("Non-interactive mode: target=%q role=%q provider=%q group=%q", target, role, provider, group)
193+
}
191194
if isNonInteractive && name == "" {
192195
if favType == config.FavoriteTypeGroups {
193196
return fmt.Errorf("name is required when using --group flag\n\nUsage:\n grant favorites add <name> --type groups --group <group>")
@@ -248,7 +251,7 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
248251

249252
// Fetch groups eligibility (best-effort, enriched with directory names)
250253
if groupsElig != nil {
251-
groups, gErr := fetchGroupsEligibility(ctx, groupsElig, eligLister, cmd.ErrOrStderr())
254+
groups, gErr := fetchGroupsEligibility(ctx, groupsElig, eligLister)
252255
if gErr == nil {
253256
for i := range groups {
254257
items = append(items, selectionItem{kind: selectionGroup, group: &groups[i]})
@@ -293,6 +296,7 @@ func runFavoritesAddWithDeps(cmd *cobra.Command, args []string, eligLister eligi
293296
}
294297
}
295298

299+
log.Info("Saving favorite %q...", name)
296300
if err := config.AddFavorite(cfg, name, fav); err != nil {
297301
return fmt.Errorf("failed to add favorite: %w", err)
298302
}
@@ -322,7 +326,7 @@ func addGroupFavorite(cmd *cobra.Command, name, group string, cfg *config.Config
322326
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
323327
defer cancel()
324328

325-
groups, err := fetchGroupsEligibility(ctx, groupsElig, eligLister, cmd.ErrOrStderr())
329+
groups, err := fetchGroupsEligibility(ctx, groupsElig, eligLister)
326330
if err != nil {
327331
return err
328332
}
@@ -392,13 +396,15 @@ func newFavoritesRemoveCommand() *cobra.Command {
392396
}
393397

394398
func runFavoritesList(cmd *cobra.Command, args []string) error {
399+
log.Info("Loading config...")
395400
cfg, _, err := config.LoadDefaultWithPath()
396401
if err != nil {
397402
return err
398403
}
399404

400405
// List favorites
401406
favorites := config.ListFavorites(cfg)
407+
log.Info("Found %d favorite(s)", len(favorites))
402408
if len(favorites) == 0 {
403409
fmt.Fprintln(cmd.OutOrStdout(), "No favorites saved. Run 'grant favorites add' to create one.")
404410
return nil
@@ -418,17 +424,20 @@ func runFavoritesList(cmd *cobra.Command, args []string) error {
418424
func runFavoritesRemove(cmd *cobra.Command, args []string) error {
419425
name := args[0]
420426

427+
log.Info("Loading config...")
421428
cfg, cfgPath, err := config.LoadDefaultWithPath()
422429
if err != nil {
423430
return err
424431
}
425432

426433
// Remove favorite
434+
log.Info("Removing favorite %q", name)
427435
if err := config.RemoveFavorite(cfg, name); err != nil {
428436
return err
429437
}
430438

431439
// Save config
440+
log.Info("Saving config...")
432441
if err := config.Save(cfg, cfgPath); err != nil {
433442
return fmt.Errorf("failed to save config: %w", err)
434443
}

cmd/favorites_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,125 @@ func TestFavoritesListCommand(t *testing.T) {
8989
}
9090
}
9191

92+
func TestFavoritesList_VerboseLogs(t *testing.T) {
93+
spy := &spyLogger{}
94+
oldLog := log
95+
log = spy
96+
defer func() { log = oldLog }()
97+
98+
tmpDir := t.TempDir()
99+
configPath := filepath.Join(tmpDir, "config.yaml")
100+
t.Setenv("GRANT_CONFIG", configPath)
101+
102+
cfg := config.DefaultConfig()
103+
_ = config.AddFavorite(cfg, "dev", config.Favorite{
104+
Provider: "azure",
105+
Target: "sub-123",
106+
Role: "Contributor",
107+
})
108+
_ = config.AddFavorite(cfg, "prod", config.Favorite{
109+
Provider: "azure",
110+
Target: "sub-456",
111+
Role: "Reader",
112+
})
113+
_ = config.Save(cfg, configPath)
114+
115+
rootCmd := newTestRootCommand()
116+
rootCmd.AddCommand(NewFavoritesCommand())
117+
_, err := executeCommand(rootCmd, "favorites", "list")
118+
if err != nil {
119+
t.Fatalf("unexpected error: %v", err)
120+
}
121+
122+
wantMessages := []string{"Loading config", "2 favorite"}
123+
for _, want := range wantMessages {
124+
found := false
125+
for _, msg := range spy.messages {
126+
if strings.Contains(msg, want) {
127+
found = true
128+
break
129+
}
130+
}
131+
if !found {
132+
t.Errorf("expected log containing %q, got: %v", want, spy.messages)
133+
}
134+
}
135+
}
136+
137+
func TestFavoritesRemove_VerboseLogs(t *testing.T) {
138+
spy := &spyLogger{}
139+
oldLog := log
140+
log = spy
141+
defer func() { log = oldLog }()
142+
143+
tmpDir := t.TempDir()
144+
configPath := filepath.Join(tmpDir, "config.yaml")
145+
t.Setenv("GRANT_CONFIG", configPath)
146+
147+
cfg := config.DefaultConfig()
148+
_ = config.AddFavorite(cfg, "dev", config.Favorite{
149+
Provider: "azure",
150+
Target: "sub-123",
151+
Role: "Contributor",
152+
})
153+
_ = config.Save(cfg, configPath)
154+
155+
rootCmd := newTestRootCommand()
156+
rootCmd.AddCommand(NewFavoritesCommand())
157+
_, err := executeCommand(rootCmd, "favorites", "remove", "dev")
158+
if err != nil {
159+
t.Fatalf("unexpected error: %v", err)
160+
}
161+
162+
wantMessages := []string{"Loading config", "Removing favorite", "Saving config"}
163+
for _, want := range wantMessages {
164+
found := false
165+
for _, msg := range spy.messages {
166+
if strings.Contains(msg, want) {
167+
found = true
168+
break
169+
}
170+
}
171+
if !found {
172+
t.Errorf("expected log containing %q, got: %v", want, spy.messages)
173+
}
174+
}
175+
}
176+
177+
func TestFavoritesAddNonInteractive_VerboseLogs(t *testing.T) {
178+
spy := &spyLogger{}
179+
oldLog := log
180+
log = spy
181+
defer func() { log = oldLog }()
182+
183+
tmpDir := t.TempDir()
184+
configPath := filepath.Join(tmpDir, "config.yaml")
185+
t.Setenv("GRANT_CONFIG", configPath)
186+
cfg := config.DefaultConfig()
187+
_ = config.Save(cfg, configPath)
188+
189+
rootCmd := newTestRootCommand()
190+
rootCmd.AddCommand(NewFavoritesCommand())
191+
_, err := executeCommand(rootCmd, "favorites", "add", "myfav", "--target", "sub-123", "--role", "Contributor")
192+
if err != nil {
193+
t.Fatalf("unexpected error: %v", err)
194+
}
195+
196+
wantMessages := []string{"Non-interactive mode", "Saving"}
197+
for _, want := range wantMessages {
198+
found := false
199+
for _, msg := range spy.messages {
200+
if strings.Contains(msg, want) {
201+
found = true
202+
break
203+
}
204+
}
205+
if !found {
206+
t.Errorf("expected log containing %q, got: %v", want, spy.messages)
207+
}
208+
}
209+
}
210+
92211
func TestFavoritesRemoveCommand(t *testing.T) {
93212
tests := []struct {
94213
name string

cmd/helpers.go

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cmd
33
import (
44
"context"
55
"fmt"
6-
"io"
76
"strings"
87
"sync"
98

@@ -18,13 +17,12 @@ type statusData struct {
1817

1918
// fetchStatusData fires sessions and all-CSP eligibility calls concurrently,
2019
// then joins results. A sessions error is fatal; eligibility errors are
21-
// gracefully degraded (empty nameMap entry, verbose warning).
20+
// gracefully degraded (empty nameMap entry, verbose warning via SDK logger).
2221
func fetchStatusData(
2322
ctx context.Context,
2423
sessionLister sessionLister,
2524
eligLister eligibilityLister,
2625
cspFilter *scamodels.CSP,
27-
errWriter io.Writer,
2826
) (*statusData, error) {
2927
type eligResult struct {
3028
csp scamodels.CSP
@@ -76,9 +74,7 @@ func fetchStatusData(
7674
nameMap := make(map[string]string)
7775
for r := range eligResults {
7876
if r.err != nil {
79-
if verbose {
80-
fmt.Fprintf(errWriter, "Warning: failed to fetch names for %s: %v\n", r.csp, r.err)
81-
}
77+
log.Info("failed to fetch names for %s: %v", r.csp, r.err)
8278
continue
8379
}
8480
for _, t := range r.targets {
@@ -99,17 +95,17 @@ func fetchStatusData(
9995
// buildDirectoryNameMap fetches Azure cloud eligibility to resolve directoryId -> name.
10096
// It looks for DIRECTORY-type workspace entries whose workspaceId matches a directory ID.
10197
// Falls back to organizationId -> first workspace name if no DIRECTORY entries exist.
102-
// Errors are silently ignored (graceful degradation — groups display without directory context).
103-
func buildDirectoryNameMap(ctx context.Context, eligLister eligibilityLister, errWriter io.Writer) map[string]string {
98+
// Errors are logged via SDK logger (graceful degradation — groups display without directory context).
99+
func buildDirectoryNameMap(ctx context.Context, eligLister eligibilityLister) map[string]string {
104100
nameMap := make(map[string]string)
105101
if eligLister == nil {
106102
return nameMap
107103
}
108104

109105
resp, err := eligLister.ListEligibility(ctx, scamodels.CSPAzure)
110106
if err != nil || resp == nil {
111-
if verbose && err != nil {
112-
fmt.Fprintf(errWriter, "Warning: failed to resolve directory names: %v\n", err)
107+
if err != nil {
108+
log.Info("failed to resolve directory names: %v", err)
113109
}
114110
return nameMap
115111
}
@@ -136,9 +132,9 @@ func buildDirectoryNameMap(ctx context.Context, eligLister eligibilityLister, er
136132

137133
// buildWorkspaceNameMap fetches eligibility for each unique CSP in sessions
138134
// concurrently and builds a workspaceID -> workspaceName map. Errors are
139-
// silently ignored (graceful degradation — the raw workspace ID is shown
135+
// logged via SDK logger (graceful degradation — the raw workspace ID is shown
140136
// as fallback).
141-
func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, sessions []scamodels.SessionInfo, errWriter io.Writer) map[string]string {
137+
func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, sessions []scamodels.SessionInfo) map[string]string {
142138
nameMap := make(map[string]string)
143139

144140
// Collect unique CSPs
@@ -178,9 +174,7 @@ func buildWorkspaceNameMap(ctx context.Context, eligLister eligibilityLister, se
178174

179175
for r := range results {
180176
if r.err != nil {
181-
if verbose {
182-
fmt.Fprintf(errWriter, "Warning: failed to fetch names for %s: %v\n", r.csp, r.err)
183-
}
177+
log.Info("failed to fetch names for %s: %v", r.csp, r.err)
184178
continue
185179
}
186180
for _, t := range r.targets {

0 commit comments

Comments
 (0)