Skip to content
Open
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 .changeset/json-output-whoami-listkey-secrets.md
Original file line number Diff line number Diff line change
@@ -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.
66 changes: 60 additions & 6 deletions cmd/account/list_key/list_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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...")

Expand All @@ -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)

Expand Down
87 changes: 85 additions & 2 deletions cmd/account/list_key/list_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package list_key

import (
"context"
"encoding/json"
"errors"
"io"
"os"
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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)
}
7 changes: 7 additions & 0 deletions cmd/secrets/common/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions cmd/secrets/common/parse_response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"os"
"strings"
Expand Down Expand Up @@ -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())
}
}
60 changes: 58 additions & 2 deletions cmd/secrets/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -74,21 +116,29 @@ 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)

return cmd
}

// 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
}
Expand All @@ -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()

Expand Down
Loading
Loading