diff --git a/.changeset/json-output-whoami-listkey-secrets.md b/.changeset/json-output-whoami-listkey-secrets.md new file mode 100644 index 00000000..dcde494c --- /dev/null +++ b/.changeset/json-output-whoami-listkey-secrets.md @@ -0,0 +1,5 @@ +--- +"cre-cli": minor +--- + +Add `--output json` / `--json` to `cre whoami`, `cre account list-key` and `cre secrets list` so their results can be consumed by scripts. JSON is the only thing written to stdout (progress and status messages go to stderr), `cre account list-key` returns `[]` when no owners are linked, and `cre secrets list` returns `{"owner", "namespace", "secrets": [...]}` and exits non-zero when the vault reports a failure. diff --git a/cmd/account/list_key/list_key.go b/cmd/account/list_key/list_key.go index 0e0f3f14..f550adcc 100644 --- a/cmd/account/list_key/list_key.go +++ b/cmd/account/list_key/list_key.go @@ -14,6 +14,7 @@ import ( "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" ) const queryListWorkflowOwners = ` @@ -47,17 +48,42 @@ type GraphQLExecutor interface { Execute(ctx context.Context, req *graphql.Request, resp any) error } +// Inputs holds resolved and validated flag values for list-key. +type Inputs struct { + OutputFormat string +} + +func resolveInputs(outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + return Inputs{OutputFormat: outputFormat}, nil +} + func New(runtimeCtx *runtime.Context) *cobra.Command { + var outputFormat string + var jsonFlag bool + cmd := &cobra.Command{ - Use: "list-key", - Short: "List workflow owners", - Long: "Fetches workflow owners linked to your organisation", - Args: cobra.NoArgs, + Use: "list-key", + Short: "List workflow owners", + Long: "Fetches workflow owners linked to your organisation", + Example: "cre account list-key\n cre account list-key --json", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := resolveInputs(outputFormat, jsonFlag) + if err != nil { + return err + } h := NewHandler(runtimeCtx) - return h.Execute(cmd.Context()) + return h.Execute(cmd.Context(), inputs) }, } + + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON array to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd } @@ -88,7 +114,30 @@ type WorkflowOwner struct { RequestProcess string `json:"requestProcess"` } -func (h *Handler) Execute(ctx context.Context) error { +// workflowOwnerJSON is the stable JSON contract for `cre account list-key --json`. +type workflowOwnerJSON struct { + WorkflowOwnerAddress string `json:"workflowOwnerAddress"` + WorkflowOwnerLabel string `json:"workflowOwnerLabel"` + Environment string `json:"environment"` + VerificationStatus string `json:"verificationStatus"` + VerifiedAt string `json:"verifiedAt"` +} + +func toOwnersJSON(owners []WorkflowOwner) []workflowOwnerJSON { + out := make([]workflowOwnerJSON, 0, len(owners)) + for _, o := range owners { + out = append(out, workflowOwnerJSON{ + WorkflowOwnerAddress: o.WorkflowOwnerAddress, + WorkflowOwnerLabel: o.WorkflowOwnerLabel, + Environment: o.Environment, + VerificationStatus: o.VerificationStatus, + VerifiedAt: o.VerifiedAt, + }) + } + return out +} + +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { spinner := ui.NewSpinner() spinner.Start("Fetching workflow owners...") @@ -106,6 +155,11 @@ func (h *Handler) Execute(ctx context.Context) error { } spinner.Stop() + + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + return workflowresolve.PrintJSON(toOwnersJSON(respEnvelope.ListWorkflowOwners.LinkedOwners)) + } + ui.Success("Workflow owners retrieved successfully") h.logOwners("Linked Owners", respEnvelope.ListWorkflowOwners.LinkedOwners) diff --git a/cmd/account/list_key/list_key_test.go b/cmd/account/list_key/list_key_test.go index 8074aa40..4be9b2f7 100644 --- a/cmd/account/list_key/list_key_test.go +++ b/cmd/account/list_key/list_key_test.go @@ -2,6 +2,7 @@ package list_key import ( "context" + "encoding/json" "errors" "io" "os" @@ -82,7 +83,7 @@ func TestExecute_Success(t *testing.T) { h := NewTestHandler(nil, &mockResp) - err := h.Execute(context.Background()) + err := h.Execute(context.Background(), Inputs{}) require.NoError(t, err) w.Close() @@ -106,7 +107,7 @@ func TestExecute_Error(t *testing.T) { h := NewTestHandler(nil, mockErrClient) - err := h.Execute(context.Background()) + err := h.Execute(context.Background(), Inputs{}) w.Close() os.Stdout = oldStdout var output strings.Builder @@ -115,3 +116,85 @@ func TestExecute_Error(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "fetch workflow owners failed") } + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func mockWithOwners(owners ...WorkflowOwner) *mockGraphQLClient { + m := &mockGraphQLClient{} + m.response.ListWorkflowOwners.LinkedOwners = owners + return m +} + +func TestExecute_JSONOutput(t *testing.T) { + h := NewTestHandler(nil, mockWithOwners( + WorkflowOwner{ + WorkflowOwnerAddress: "0xabc", + WorkflowOwnerLabel: "Alice", + Environment: "PRODUCTION", + VerificationStatus: "VERIFIED", + VerifiedAt: "2025-07-30", + ChainSelector: "111", + ContractAddress: "0xcontract1", + }, + WorkflowOwner{WorkflowOwnerAddress: "0xdef", WorkflowOwnerLabel: "Bob"}, + )) + + var err error + out := captureStdout(t, func() { err = h.Execute(context.Background(), Inputs{OutputFormat: "json"}) }) + require.NoError(t, err) + + var got []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout must be only JSON, got %q", out) + require.Len(t, got, 2) + assert.Equal(t, map[string]any{ + "workflowOwnerAddress": "0xabc", + "workflowOwnerLabel": "Alice", + "environment": "PRODUCTION", + "verificationStatus": "VERIFIED", + "verifiedAt": "2025-07-30", + }, got[0]) + assert.Equal(t, "0xdef", got[1]["workflowOwnerAddress"]) +} + +func TestExecute_JSONOutput_EmptyIsArray(t *testing.T) { + h := NewTestHandler(nil, mockWithOwners()) + + var err error + out := captureStdout(t, func() { err = h.Execute(context.Background(), Inputs{OutputFormat: "json"}) }) + require.NoError(t, err) + assert.Equal(t, "[]", strings.TrimSpace(out)) +} + +func TestExecute_JSONOutput_ErrorPrintsNothing(t *testing.T) { + h := NewTestHandler(nil, &mockGraphQLClient{err: errors.New("network error")}) + + var err error + out := captureStdout(t, func() { err = h.Execute(context.Background(), Inputs{OutputFormat: "json"}) }) + require.Error(t, err) + assert.Empty(t, out) +} + +func TestResolveInputs(t *testing.T) { + in, err := resolveInputs("", true) + require.NoError(t, err) + assert.Equal(t, "json", in.OutputFormat) + + in, err = resolveInputs("", false) + require.NoError(t, err) + assert.Empty(t, in.OutputFormat) + + _, err = resolveInputs("csv", false) + require.Error(t, err) +} diff --git a/cmd/secrets/common/handler.go b/cmd/secrets/common/handler.go index 3ae32b08..64695848 100644 --- a/cmd/secrets/common/handler.go +++ b/cmd/secrets/common/handler.go @@ -84,6 +84,9 @@ type Handler struct { Settings *settings.Settings execCtx context.Context + // ListResponseHandler, when set, receives the decoded secrets/list response instead of the default printer. + ListResponseHandler func(*vault.ListSecretIdentifiersResponse) error + vaultValidationDecided bool skipVaultValidation bool capRegRPCURL string @@ -773,6 +776,10 @@ func (h *Handler) ParseVaultGatewayResponse(method, requestID string, respBody [ return fmt.Errorf("failed to decode list payload: %w", err) } + if h.ListResponseHandler != nil { + return h.ListResponseHandler(&p) + } + if !p.GetSuccess() { ui.Error(fmt.Sprintf("Secret list failed: error=%s", p.GetError())) break diff --git a/cmd/secrets/common/parse_response_test.go b/cmd/secrets/common/parse_response_test.go index 6ed1b4cb..e1fa229e 100644 --- a/cmd/secrets/common/parse_response_test.go +++ b/cmd/secrets/common/parse_response_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "os" "strings" @@ -435,3 +436,36 @@ func TestParseVaultGatewayResponse_BadPayloadForList(t *testing.T) { t.Fatalf("expected decode error for list payload, got: %v", err) } } + +func TestParseVaultGatewayResponse_List_ListResponseHandler(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + var buf bytes.Buffer + h := newTestHandler(&buf) + + var got *vault.ListSecretIdentifiersResponse + h.ListResponseHandler = func(resp *vault.ListSecretIdentifiersResponse) error { + got = resp + return errors.New("handler error") + } + + body := encodeRPCBodyFromPayload(buildListPayloadProtoSuccessWithItems(t)) + err := h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "", body) + + w.Close() + os.Stdout = oldStdout + var output strings.Builder + _, _ = io.Copy(&output, r) + + if err == nil || !strings.Contains(err.Error(), "handler error") { + t.Fatalf("expected handler error to propagate, got: %v", err) + } + if got == nil || len(got.GetIdentifiers()) != 2 || got.GetIdentifiers()[0].GetKey() != "l1" { + t.Fatalf("expected decoded list response with 2 identifiers, got: %v", got) + } + if output.Len() != 0 { + t.Fatalf("expected default printer to be bypassed, got stdout:\n%s", output.String()) + } +} diff --git a/cmd/secrets/list/list.go b/cmd/secrets/list/list.go index 6796ab9a..1dabb466 100644 --- a/cmd/secrets/list/list.go +++ b/cmd/secrets/list/list.go @@ -26,15 +26,52 @@ import ( "github.com/smartcontractkit/cre-cli/internal/settings" "github.com/smartcontractkit/cre-cli/internal/types" "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" ) +// secretsListJSON is the stable JSON contract for `cre secrets list --json`. +type secretsListJSON struct { + Owner string `json:"owner"` + Namespace string `json:"namespace"` + Secrets []secretIdentifierJSON `json:"secrets"` +} + +type secretIdentifierJSON struct { + ID string `json:"id"` + Namespace string `json:"namespace"` + Owner string `json:"owner"` +} + +func printSecretsJSON(owner, namespace string, resp *vault.ListSecretIdentifiersResponse) error { + if !resp.GetSuccess() { + return fmt.Errorf("secret list failed: %s", resp.GetError()) + } + + secrets := make([]secretIdentifierJSON, 0, len(resp.GetIdentifiers())) + for _, id := range resp.GetIdentifiers() { + if id == nil { + continue + } + secrets = append(secrets, secretIdentifierJSON{ + ID: id.GetKey(), + Namespace: id.GetNamespace(), + Owner: id.GetOwner(), + }) + } + return workflowresolve.PrintJSON(secretsListJSON{Owner: owner, Namespace: namespace, Secrets: secrets}) +} + // cre secrets list --timeout 1h func New(ctx *runtime.Context) *cobra.Command { var namespace string + var outputFormat string + var jsonFlag bool cmd := &cobra.Command{ Use: "list", Short: "Lists secret identifiers for the current owner address in the given namespace.", + Example: "cre secrets list\n" + + " cre secrets list --namespace main --json", RunE: func(cmd *cobra.Command, args []string) error { if ctx.Viper.GetBool(settings.Flags.NonInteractive.Name) && !ctx.Viper.GetBool(settings.Flags.SkipConfirmation.Name) { ui.ErrorWithSuggestions( @@ -44,6 +81,11 @@ func New(ctx *runtime.Context) *cobra.Command { return fmt.Errorf("missing required flags for --non-interactive mode") } + format, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return err + } + secretsAuth, err := cmd.Flags().GetString("secrets-auth") if err != nil { return err @@ -74,11 +116,13 @@ func New(ctx *runtime.Context) *cobra.Command { return fmt.Errorf("invalid --timeout: must be greater than 0 and less than %dh (%dd)", maxHours, maxDays) } - return Execute(cmd.Context(), h, namespace, duration, secretsAuth) + return Execute(cmd.Context(), h, namespace, duration, secretsAuth, format) }, } cmd.Flags().StringVar(&namespace, "namespace", "main", "Namespace to list (default: main)") + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON object to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") settings.AddTxnTypeFlags(cmd) settings.AddSkipConfirmation(cmd) @@ -86,9 +130,15 @@ func New(ctx *runtime.Context) *cobra.Command { } // Execute performs: build request → (MSIG step 1 bundle OR EOA allowlist+post) → parse. -func Execute(ctx context.Context, h *common.Handler, namespace string, duration time.Duration, secretsAuth string) error { +// With outputFormat "json", progress goes to stderr and only the list result is written to stdout. +func Execute(ctx context.Context, h *common.Handler, namespace string, duration time.Duration, secretsAuth, outputFormat string) error { defer h.CloseCapRegClient() + if outputFormat == workflowresolve.OutputFormatJSON { + restore := ui.RedirectStdoutToStderr() + defer restore() + } + if _, err := h.EnsureVaultValidationOrConsent(ctx); err != nil { return err } @@ -115,6 +165,12 @@ func Execute(ctx context.Context, h *common.Handler, namespace string, duration return err } + if outputFormat == workflowresolve.OutputFormatJSON { + h.ListResponseHandler = func(resp *vault.ListSecretIdentifiersResponse) error { + return printSecretsJSON(owner, namespace, resp) + } + } + // Fresh request ID requestID := uuid.New().String() diff --git a/cmd/secrets/list/list_test.go b/cmd/secrets/list/list_test.go index 90490f69..26cc9c47 100644 --- a/cmd/secrets/list/list_test.go +++ b/cmd/secrets/list/list_test.go @@ -1,15 +1,39 @@ package list import ( + "encoding/json" + "io" + "os" + "strings" "testing" "github.com/spf13/viper" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + + "github.com/smartcontractkit/cre-cli/cmd/secrets/common" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/settings" ) +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + func TestNonInteractive_WithoutYes_ReturnsError(t *testing.T) { t.Parallel() v := viper.New() @@ -39,3 +63,104 @@ func TestNonInteractive_WithYes_PassesGuard(t *testing.T) { require.NotContains(t, err.Error(), "missing required flags for --non-interactive mode") } } + +func TestInvalidOutputFormat_ReturnsError(t *testing.T) { + t.Parallel() + v := viper.New() + v.Set(settings.Flags.NonInteractive.Name, true) + v.Set(settings.Flags.SkipConfirmation.Name, true) + + cmd := New(&runtime.Context{Viper: v}) + cmd.SetArgs([]string{"--output", "csv"}) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), "csv") +} + +func TestOutputFlags_Registered(t *testing.T) { + t.Parallel() + cmd := New(&runtime.Context{Viper: viper.New()}) + require.NotNil(t, cmd.Flags().Lookup("output")) + require.NotNil(t, cmd.Flags().Lookup("json")) +} + +func TestPrintSecretsJSON(t *testing.T) { + resp := &vault.ListSecretIdentifiersResponse{ + Success: true, + Identifiers: []*vault.SecretIdentifier{ + {Key: "API_KEY", Owner: "0xabc", Namespace: "main"}, + nil, + {Key: "DB_PASSWORD", Owner: "0xabc", Namespace: "main"}, + }, + } + + var err error + out := captureStdout(t, func() { err = printSecretsJSON("0xabc", "main", resp) }) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout must be only JSON, got %q", out) + assert.Equal(t, map[string]any{ + "owner": "0xabc", + "namespace": "main", + "secrets": []any{ + map[string]any{"id": "API_KEY", "namespace": "main", "owner": "0xabc"}, + map[string]any{"id": "DB_PASSWORD", "namespace": "main", "owner": "0xabc"}, + }, + }, got) +} + +func TestPrintSecretsJSON_EmptyIsArray(t *testing.T) { + var err error + out := captureStdout(t, func() { + err = printSecretsJSON("0xabc", "main", &vault.ListSecretIdentifiersResponse{Success: true}) + }) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, []any{}, got["secrets"]) +} + +func TestPrintSecretsJSON_FailureReturnsError(t *testing.T) { + var err error + out := captureStdout(t, func() { + err = printSecretsJSON("0xabc", "main", &vault.ListSecretIdentifiersResponse{Success: false, Error: "boom"}) + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "boom") + assert.Empty(t, out) +} + +func TestListResponseHandler_ReplacesDefaultOutput(t *testing.T) { + payload, err := protojson.Marshal(&vault.ListSecretIdentifiersResponse{ + Success: true, + Identifiers: []*vault.SecretIdentifier{{Key: "API_KEY", Owner: "0xabc", Namespace: "main"}}, + }) + require.NoError(t, err) + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": "req-1", + "result": map[string]any{"payload": json.RawMessage(payload)}, + }) + require.NoError(t, err) + + h := &common.Handler{} + h.ListResponseHandler = func(resp *vault.ListSecretIdentifiersResponse) error { + return printSecretsJSON("0xabc", "main", resp) + } + + out := captureStdout(t, func() { + err = h.ParseVaultGatewayResponse(vaulttypes.MethodSecretsList, "req-1", body) + }) + require.NoError(t, err) + + var got secretsListJSON + require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout must be only JSON, got %q", out) + assert.Equal(t, secretsListJSON{ + Owner: "0xabc", + Namespace: "main", + Secrets: []secretIdentifierJSON{{ID: "API_KEY", Namespace: "main", Owner: "0xabc"}}, + }, got) +} diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go index 01051ead..965bb6df 100644 --- a/cmd/whoami/whoami.go +++ b/cmd/whoami/whoami.go @@ -13,19 +13,54 @@ import ( "github.com/smartcontractkit/cre-cli/internal/environments" "github.com/smartcontractkit/cre-cli/internal/runtime" "github.com/smartcontractkit/cre-cli/internal/ui" + "github.com/smartcontractkit/cre-cli/internal/workflowresolve" ) +// Inputs holds resolved and validated flag values for whoami. +type Inputs struct { + OutputFormat string +} + +func resolveInputs(outputFormat string, jsonFlag bool) (Inputs, error) { + outputFormat, err := workflowresolve.ResolveOutputFormat(outputFormat, jsonFlag) + if err != nil { + return Inputs{}, err + } + return Inputs{OutputFormat: outputFormat}, nil +} + +// whoamiJSON is the stable JSON contract for `cre whoami --json`. +type whoamiJSON struct { + OrganizationID string `json:"organizationId"` + OrganizationName string `json:"organizationName"` + // Email is empty for API-key auth, which has no account details to fetch. + Email string `json:"email,omitempty"` + Environment string `json:"environment"` +} + func New(runtimeCtx *runtime.Context) *cobra.Command { + var outputFormat string + var jsonFlag bool + cmd := &cobra.Command{ - Use: "whoami", - Short: "Show your current account details", - Long: "Fetches your account details (email and organization ID).", - Args: cobra.NoArgs, + Use: "whoami", + Short: "Show your current account details", + Long: "Fetches your account details (email and organization ID).", + Example: "cre whoami\n cre whoami --json", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { + inputs, err := resolveInputs(outputFormat, jsonFlag) + if err != nil { + return err + } h := NewHandler(runtimeCtx) - return h.Execute(cmd.Context()) + return h.Execute(cmd.Context(), inputs) }, } + + cmd.Flags().StringVar(&outputFormat, "output", "", `Output format: "json" prints a JSON object to stdout`) + cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON (shorthand for --output=json)") + return cmd } @@ -43,7 +78,7 @@ func NewHandler(ctx *runtime.Context) *Handler { } } -func (h *Handler) Execute(ctx context.Context) error { +func (h *Handler) Execute(ctx context.Context, inputs Inputs) error { var query string if h.credentials.APIKey == "" { query = ` @@ -88,6 +123,22 @@ func (h *Handler) Execute(ctx context.Context) error { return fmt.Errorf("graphql request failed: %w", err) } + if inputs.OutputFormat == workflowresolve.OutputFormatJSON { + out := whoamiJSON{ + OrganizationID: respEnvelope.GetOrganization.OrganizationID, + OrganizationName: respEnvelope.GetOrganization.DisplayName, + Environment: h.environmentSet.EnvName, + } + // EnvLabel is blank for production; scripts need the canonical name. + if out.Environment == "" { + out.Environment = environments.DefaultEnv + } + if respEnvelope.GetAccountDetails != nil { + out.Email = respEnvelope.GetAccountDetails.EmailAddress + } + return workflowresolve.PrintJSON(out) + } + // Get deployment access status deployAccess, err := h.credentials.GetDeploymentAccessStatus() if err != nil { diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go index 103c0da5..d46850d4 100644 --- a/cmd/whoami/whoami_test.go +++ b/cmd/whoami/whoami_test.go @@ -12,6 +12,8 @@ import ( "testing" "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/smartcontractkit/cre-cli/cmd/whoami" "github.com/smartcontractkit/cre-cli/internal/credentials" @@ -124,7 +126,7 @@ func TestHandlerExecute(t *testing.T) { os.Stdout = w h := whoami.NewHandler(rtCtx) - err := h.Execute(context.Background()) + err := h.Execute(context.Background(), whoami.Inputs{}) w.Close() os.Stdout = oldStdout @@ -155,3 +157,77 @@ func TestHandlerExecute(t *testing.T) { }) } } + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf strings.Builder + _, _ = io.Copy(&buf, r) + return buf.String() +} + +func runWhoami(t *testing.T, creds *credentials.Credentials, envName string, resp map[string]any, args ...string) (string, error) { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": resp}) + })) + t.Cleanup(ts.Close) + + logger := zerolog.Nop() + cmd := whoami.New(&runtime.Context{ + Credentials: creds, + Logger: &logger, + EnvironmentSet: &environments.EnvironmentSet{GraphQLURL: ts.URL, EnvName: envName}, + }) + cmd.SetArgs(args) + + var err error + out := captureStdout(t, func() { err = cmd.Execute() }) + return out, err +} + +func TestWhoami_JSONOutput(t *testing.T) { + out, err := runWhoami(t, &credentials.Credentials{}, "STAGING", map[string]any{ + "getAccountDetails": map[string]string{"emailAddress": "alice@example.com"}, + "getOrganization": map[string]string{"organizationId": "org-42", "displayName": "Alice's Org"}, + }, "--json") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout must be only JSON, got %q", out) + assert.Equal(t, map[string]any{ + "organizationId": "org-42", + "organizationName": "Alice's Org", + "email": "alice@example.com", + "environment": "STAGING", + }, got) +} + +func TestWhoami_JSONOutput_APIKeyOmitsEmail(t *testing.T) { + creds := &credentials.Credentials{AuthType: credentials.AuthTypeApiKey, APIKey: "test-key"} + out, err := runWhoami(t, creds, "", map[string]any{ + "getOrganization": map[string]string{"organizationId": "org-42", "displayName": "Alice's Org"}, + }, "--output", "json") + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &got), "stdout must be only JSON, got %q", out) + assert.NotContains(t, got, "email") + assert.Equal(t, "org-42", got["organizationId"]) + assert.Equal(t, "Alice's Org", got["organizationName"]) + assert.Equal(t, "PRODUCTION", got["environment"]) +} + +func TestWhoami_InvalidOutputFormat(t *testing.T) { + out, err := runWhoami(t, &credentials.Credentials{}, "", map[string]any{}, "--output", "csv") + require.Error(t, err) + assert.Contains(t, err.Error(), "csv") + assert.Empty(t, out) +} diff --git a/docs/cre_account_list-key.md b/docs/cre_account_list-key.md index 4e55d1b7..bace3c74 100644 --- a/docs/cre_account_list-key.md +++ b/docs/cre_account_list-key.md @@ -10,10 +10,19 @@ Fetches workflow owners linked to your organisation cre account list-key [optional flags] ``` +### Examples + +``` +cre account list-key + cre account list-key --json +``` + ### Options ``` - -h, --help help for list-key + -h, --help help for list-key + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints a JSON array to stdout ``` ### Options inherited from parent commands diff --git a/docs/cre_secrets_list.md b/docs/cre_secrets_list.md index be9ca891..2e6a7829 100644 --- a/docs/cre_secrets_list.md +++ b/docs/cre_secrets_list.md @@ -6,11 +6,20 @@ Lists secret identifiers for the current owner address in the given namespace. cre secrets list [optional flags] ``` +### Examples + +``` +cre secrets list + cre secrets list --namespace main --json +``` + ### Options ``` -h, --help help for list + --json Output as JSON (shorthand for --output=json) --namespace string Namespace to list (default: main) (default "main") + --output string Output format: "json" prints a JSON object to stdout --unsigned If set, the command will either return the raw transaction instead of sending it to the network or execute the second step of secrets operations using a previously generated raw transaction --yes If set, the command will skip the confirmation prompt and proceed with the operation even if it is potentially destructive ``` diff --git a/docs/cre_whoami.md b/docs/cre_whoami.md index d83c0631..81e1bc6f 100644 --- a/docs/cre_whoami.md +++ b/docs/cre_whoami.md @@ -10,10 +10,19 @@ Fetches your account details (email and organization ID). cre whoami [optional flags] ``` +### Examples + +``` +cre whoami + cre whoami --json +``` + ### Options ``` - -h, --help help for whoami + -h, --help help for whoami + --json Output as JSON (shorthand for --output=json) + --output string Output format: "json" prints a JSON object to stdout ``` ### Options inherited from parent commands diff --git a/internal/ui/output.go b/internal/ui/output.go index 96f747bb..9fe2fcb9 100644 --- a/internal/ui/output.go +++ b/internal/ui/output.go @@ -2,7 +2,9 @@ package ui import ( "fmt" + "io" "os" + "sync/atomic" ) // verbose disables animated UI components (spinners) to avoid @@ -14,17 +16,34 @@ func SetVerbose(v bool) { verbose = v } +// stdoutToStderr is read on every write so tests that swap os.Stdout/os.Stderr keep working. +var stdoutToStderr atomic.Bool + +func stdout() io.Writer { + if stdoutToStderr.Load() { + return os.Stderr + } + return os.Stdout +} + +// RedirectStdoutToStderr sends the stdout helpers below to stderr so machine-readable +// output can be the only thing on stdout. Call the returned func to restore. +func RedirectStdoutToStderr() (restore func()) { + prev := stdoutToStderr.Swap(true) + return func() { stdoutToStderr.Store(prev) } +} + // Output helpers - use these for consistent styled output across commands. // These functions make it easy to migrate from raw fmt.Println calls. // Title prints a styled title/header (high visibility - Chainlink Blue) func Title(text string) { - fmt.Println(TitleStyle.Render(text)) + fmt.Fprintln(stdout(), TitleStyle.Render(text)) } // Success prints a success message with checkmark (Green) func Success(text string) { - fmt.Println(SuccessStyle.Render("✓ " + text)) + fmt.Fprintln(stdout(), SuccessStyle.Render("✓ "+text)) } // Error prints an error message to stderr (Orange - high contrast) @@ -67,7 +86,7 @@ func WarningWithSuggestions(text string, suggestions []string) { // Dim prints dimmed/secondary text (Gray - less important) func Dim(text string) { - fmt.Println(DimStyle.Render(" " + text)) + fmt.Fprintln(stdout(), DimStyle.Render(" "+text)) } // EnvContext prints a dim "Environment: