Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 16 additions & 1 deletion general/api/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@ func exchangeAndPrint(client *httpclient.HttpClient, c commandContext, method, f

log.Info("Http Status:", resp.StatusCode)

isError := resp.StatusCode < 200 || resp.StatusCode > 399

// Opt-in (JFROG_CLI_ERROR_OUTPUT_FORMAT=json, or --format=json auto-promote):
// for HTTP error responses, emit the response as a structured JSON object on
// stdout — the same data channel where the successful body would have gone —
// and skip dumping the raw body. Stderr keeps its log lines untouched, so
// `jf api ... | jq` works in both success and error cases.
if isError && cliutils.HandleHTTPErrorAsJSON(stdOut, &errorutils.HttpResponseError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: respBody,
}) {
return cli.NewExitError("", 1)
}

if _, err = stdOut.Write(respBody); err != nil {
return errorutils.CheckError(err)
}
Expand All @@ -194,7 +209,7 @@ func exchangeAndPrint(client *httpclient.HttpClient, c commandContext, method, f
}
}

if resp.StatusCode < 200 || resp.StatusCode > 399 {
if isError {
log.Warn("jf api:", method, fullURL, "returned", resp.Status)
// Exit code only: a non-empty ExitError message would be printed again by urfave/cli's
// HandleExitCoder after the response body when stdout and stderr are combined.
Expand Down
81 changes: 81 additions & 0 deletions general/api/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -777,3 +778,83 @@ func TestRunApiCmd_UsageReportCompletes(t *testing.T) {
// Reporter signals immediately, so the call should not approach the timeout.
assert.Less(t, time.Since(start), 2*time.Second)
}

// newTestServerWithStatus starts an httptest server returning the given status
// and body. Used to drive the HTTP-error code path in `jf api`.
func newTestServerWithStatus(t *testing.T, status int, body []byte, contentType string) *coreConfig.ServerDetails {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
t.Log(err)
}
}))
t.Cleanup(srv.Close)
return &coreConfig.ServerDetails{Url: srv.URL, AccessToken: "my-token"}
}

// TestApiJSONErrorMode_EmitsJSONOnStdout verifies that when the opt-in
// JFROG_CLI_ERROR_OUTPUT_FORMAT=json env var is set, `jf api` emits a
// structured JSON object describing the HTTP error on stdout (the data
// channel) rather than the raw body. Stderr keeps its log lines untouched.
func TestApiJSONErrorMode_EmitsJSONOnStdout(t *testing.T) {
t.Setenv("JFROG_CLI_ERROR_OUTPUT_FORMAT", "json")

body := []byte(`{"errors":[{"code":"UNAUTHORIZED","message":"bad creds"}]}`)
serverDetails := newTestServerWithStatus(t, http.StatusUnauthorized, body, "application/json")
ctx := newMockContext(&commandArgs{path: "/unauthorized"})

var stdOut bytes.Buffer
err := runApiCmd(ctx, serverDetails, &stdOut, nil)

// Must signal failure (urfave/cli ExitError with code 1, empty message).
assert.Error(t, err)

// stdout must contain a parseable JSON object with the structured fields —
// not the raw response body as in legacy mode.
var out map[string]interface{}
require.NoError(t, json.Unmarshal(stdOut.Bytes(), &out), "stdout must be parseable JSON")
assert.EqualValues(t, http.StatusUnauthorized, out["status_code"])
assert.Equal(t, "401 Unauthorized", out["status"])
bodyOut, ok := out["body"].(map[string]interface{})
require.True(t, ok, "body field must be a nested JSON object")
errorsArr, ok := bodyOut["errors"].([]interface{})
require.True(t, ok)
assert.NotEmpty(t, errorsArr)
}

// TestApiJSONErrorMode_SuccessUnchanged verifies that 2xx responses still
// write the body to stdout even when the env var is set — JSON mode only
// changes the error path.
func TestApiJSONErrorMode_SuccessUnchanged(t *testing.T) {
t.Setenv("JFROG_CLI_ERROR_OUTPUT_FORMAT", "json")

body := []byte(`{"ok":true}`)
serverDetails := newTestServerWithStatus(t, http.StatusOK, body, "application/json")
ctx := newMockContext(&commandArgs{path: "/ok"})

var stdOut bytes.Buffer
require.NoError(t, runApiCmd(ctx, serverDetails, &stdOut, nil))
assert.Equal(t, string(body), strings.TrimSpace(stdOut.String()))
}

// TestApiDefaultMode_ErrorStillDumpsBody guards against regressing the legacy
// curl-like behavior: with the env var unset, errors still dump the body to
// stdout and the command exits non-zero.
func TestApiDefaultMode_ErrorStillDumpsBody(t *testing.T) {
t.Setenv("JFROG_CLI_ERROR_OUTPUT_FORMAT", "")

// 4xx is returned as a non-retried response; 5xx would trip the client's
// retry loop and never reach the body-write branch under test.
body := []byte(`{"errors":["nope"]}`)
serverDetails := newTestServerWithStatus(t, http.StatusUnauthorized, body, "application/json")
ctx := newMockContext(&commandArgs{path: "/unauthorized-default"})

var stdOut bytes.Buffer
err := runApiCmd(ctx, serverDetails, &stdOut, nil)
assert.Error(t, err)
assert.Equal(t, string(body), strings.TrimSpace(stdOut.String()))
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ require (
github.com/jfrog/jfrog-cli-evidence v0.9.4
github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260430094150-ce7d9b371c6f
github.com/jfrog/jfrog-cli-security v1.29.0
github.com/jfrog/jfrog-client-go v1.55.1-0.20260521115926-32f082854b39
github.com/jfrog/jfrog-client-go v1.55.1-0.20260522071027-8b60a715d6e4
github.com/jszwec/csvutil v1.10.0
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,10 @@ github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260430094150-ce7d9b371c
github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260430094150-ce7d9b371c6f/go.mod h1:JUdq/dQoNscpta62FDCAcaSVbvcCOr5VkH8UeGTG1HQ=
github.com/jfrog/jfrog-cli-security v1.29.0 h1:TN2OCA5i/iPbikQWzSwVqGvySvIvw1P6rPga+DbVBOI=
github.com/jfrog/jfrog-cli-security v1.29.0/go.mod h1:q38TPlxortIJvbyD3u9P9UhHwyx007tEb9WbXlXw2E0=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260521115926-32f082854b39 h1:cXWtxiTOWFha3yBWh6FDxr0qCNVd0Q40rB/rB+bU3eY=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260521115926-32f082854b39/go.mod h1:k3PqoFpS6XDt9/4xg3pS8J8JUvxtaz1w2vdTdodknGk=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260518090239-a4f67e2bd8cb h1:ew1foDyxCyqKbSz06ybrrHruf4OIbhti/YLwaa0J8dI=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260518090239-a4f67e2bd8cb/go.mod h1:sCE06+GngPoyrGO0c+vmhgMoVSP83UMNiZnIuNPzU8U=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260522071027-8b60a715d6e4 h1:ujVu255rk51l9Uz1t75DdsVoa2MH+lYNV2cB2xDWjPM=
github.com/jfrog/jfrog-client-go v1.55.1-0.20260522071027-8b60a715d6e4/go.mod h1:k3PqoFpS6XDt9/4xg3pS8J8JUvxtaz1w2vdTdodknGk=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY=
Expand Down
14 changes: 14 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ func main() {
if cleanupErr := fileutils.CleanOldDirs(); cleanupErr != nil {
clientlog.Warn("failed while attempting to cleanup old CLI temp directories:", cleanupErr)
}
// Opt-in (JFROG_CLI_ERROR_OUTPUT_FORMAT=json, or --format=json auto-promoted
// in execMain): emit HTTP response errors as structured JSON on stdout —
// the data channel — so scripts can pipe to jq. Stderr continues to receive
// the existing logger output (Info/Warn lines, trace ID) unchanged. Covers
// every command that hits the platform via the standard jfrog-client-go
// helpers, including OIDC token-exchange failures.
if cliutils.HandleHTTPErrorAsJSON(os.Stdout, err) {
os.Exit(coreutils.GetExitCode(err, 0, 0, false).Code)
}
coreutils.ExitOnErr(err)
}

Expand All @@ -97,6 +106,11 @@ func execMain() error {
app.Version = cliutils.GetVersion()
args := os.Args
cliutils.SetCliExecutableName(args[0])
// Auto-promote --format=json (already supported by many commands and by
// commands.Exec subcommands) to JFROG_CLI_ERROR_OUTPUT_FORMAT=json so that
// HTTP error responses are emitted as JSON on stderr without requiring a
// second env var. Explicit env var wins.
cliutils.EnableJSONErrorIfFormatJSON(args)
app.EnableBashCompletion = true
commands, err := getCommands()
if err != nil {
Expand Down
9 changes: 9 additions & 0 deletions utils/cliutils/cli_consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,13 @@ const (
//#nosec G101
JfrogCliGithubToken = "JFROG_CLI_GITHUB_TOKEN"
JfrogCliHideSurvey = "JFROG_CLI_HIDE_SURVEY"
// JfrogCliErrorOutputFormat controls how HTTP response errors are surfaced.
// Set to "json" to emit the structured response (status code + body) as JSON
// on stderr instead of the default human-readable text. Unset or "text" keeps
// the legacy behavior. Applies uniformly to all commands, including OIDC
// token-exchange failures.
JfrogCliErrorOutputFormat = "JFROG_CLI_ERROR_OUTPUT_FORMAT"
)

// ErrorFormatJSON is the env-var value that switches HTTP error reporting to JSON-on-stderr.
const ErrorFormatJSON = "json"
151 changes: 151 additions & 0 deletions utils/cliutils/errorformat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package cliutils

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"

"github.com/jfrog/jfrog-client-go/utils/errorutils"
)

// legacyHTTPErrPrefix matches the text produced by errorutils.HttpResponseError.Error()
// (and historically by GenerateResponseError). When intermediate code wraps the
// typed error in a way that strips the chain (e.g. errors.New(err.Error() + ...)),
// we fall back to parsing this prefix so that JSON emission still applies.
const legacyHTTPErrPrefix = "server response: "

// EnableJSONErrorIfFormatJSON inspects args for "--format=json" / "--format json"
// (or the single-dash "-format" variant) and, when found, sets
// JFROG_CLI_ERROR_OUTPUT_FORMAT=json for the lifetime of this process.
//
// Rationale: any command (direct or one dispatched via commands.Exec) that
// already accepts --format=json for its success output should produce
// machine-readable JSON for HTTP error responses too, so a single flag covers
// both code paths and scripts can rely on `jq` end-to-end.
//
// Must be called early in main() — the env var is read by HandleHTTPErrorAsJSON,
// which fires both during command execution (e.g. `jf api`) and at process exit.
// An explicit env var setting wins: if JFROG_CLI_ERROR_OUTPUT_FORMAT is already
// set, the function is a no-op.
func EnableJSONErrorIfFormatJSON(args []string) {
if os.Getenv(JfrogCliErrorOutputFormat) != "" {
return
}
for i, a := range args {
if val, ok := matchFormatEquals(a); ok && strings.EqualFold(val, ErrorFormatJSON) {
_ = os.Setenv(JfrogCliErrorOutputFormat, ErrorFormatJSON)
return
}
if (a == "--format" || a == "-format") && i+1 < len(args) &&
strings.EqualFold(args[i+1], ErrorFormatJSON) {
_ = os.Setenv(JfrogCliErrorOutputFormat, ErrorFormatJSON)
return
}
}
}

func matchFormatEquals(a string) (string, bool) {
for _, prefix := range []string{"--format=", "-format="} {
if strings.HasPrefix(a, prefix) {
return a[len(prefix):], true
}
}
return "", false
}

// HandleHTTPErrorAsJSON emits err as a JSON object to w when:
//
// 1. JFROG_CLI_ERROR_OUTPUT_FORMAT is set to "json", and
// 2. err wraps a *errorutils.HttpResponseError (the typed error returned by
// jfrog-client-go's CheckResponseStatus / CheckResponseStatusWithBody),
// or its legacy "server response: ..." text equivalent.
//
// w is expected to be the caller's stdout sink: the JSON object is *data* a
// script consumer wants to parse alongside successful command output. Stderr
// continues to carry the human-readable logger output (Info/Warn/Error lines)
// untouched, which a downstream `| jq` pipeline can simply ignore.
//
// Returns true when JSON was emitted. The caller is then responsible for setting
// the process exit code and suppressing any subsequent text logging of the same
// error. Returns false in every other case (env var unset/other value, err nil,
// or err is not an HTTP response error), so callers can fall through to the
// default text reporting path (which uses stderr).
//
// This covers all command-paths that use the standard jfrog-client-go HTTP
// helpers, including OIDC token exchange (errors are wrapped with %w so
// errors.As walks through them).
func HandleHTTPErrorAsJSON(w io.Writer, err error) bool {
if err == nil {
return false
}
if !strings.EqualFold(strings.TrimSpace(os.Getenv(JfrogCliErrorOutputFormat)), ErrorFormatJSON) {
return false
}
httpErr := extractHTTPResponseError(err)
if httpErr == nil {
return false
}
payload := map[string]interface{}{
"status_code": httpErr.StatusCode,
"status": httpErr.Status,
}
if len(httpErr.Body) > 0 {
var parsed interface{}
if json.Unmarshal(httpErr.Body, &parsed) == nil {
payload["body"] = parsed
} else {
payload["body"] = string(httpErr.Body)
}
}
encoded, marshalErr := json.MarshalIndent(payload, "", " ")
if marshalErr != nil {
return false
}
_, _ = fmt.Fprintln(w, string(encoded))
return true
}

// extractHTTPResponseError returns the underlying *errorutils.HttpResponseError
// if err wraps one (via errors.As). When no typed error is found, it falls back
// to parsing the legacy "server response: <STATUS>\n<BODY>" text format produced
// by errorutils.GenerateResponseError. The fallback handles cases where
// intermediate code stripped the wrap chain (e.g. errors.New(err.Error() + ...))
// while preserving the human-readable message.
func extractHTTPResponseError(err error) *errorutils.HttpResponseError {
var httpErr *errorutils.HttpResponseError
if errors.As(err, &httpErr) && httpErr != nil {
return httpErr
}
return parseLegacyHTTPResponseError(err.Error())
}

func parseLegacyHTTPResponseError(msg string) *errorutils.HttpResponseError {
idx := strings.Index(msg, legacyHTTPErrPrefix)
if idx < 0 {
return nil
}
rest := msg[idx+len(legacyHTTPErrPrefix):]
statusLine, bodyStr, _ := strings.Cut(rest, "\n")
statusLine = strings.TrimSpace(statusLine)

// First token must be a numeric status code (e.g. "401 Unauthorized").
// Cap the digit count so Atoi cannot overflow on malformed input — real
// HTTP statuses are 3 digits; 9 leaves plenty of headroom under int32.
codeEnd := 0
for codeEnd < len(statusLine) && codeEnd < 9 && statusLine[codeEnd] >= '0' && statusLine[codeEnd] <= '9' {
Comment thread
ehl-jf marked this conversation as resolved.
Outdated
codeEnd++
}
if codeEnd == 0 {
return nil
}
code, _ := strconv.Atoi(statusLine[:codeEnd])
return &errorutils.HttpResponseError{
StatusCode: code,
Status: statusLine,
Body: []byte(strings.TrimRight(bodyStr, "\n")),
}
}
Loading
Loading