diff --git a/buildtools/cli.go b/buildtools/cli.go index 184913c30..fb74931d7 100644 --- a/buildtools/cli.go +++ b/buildtools/cli.go @@ -707,9 +707,12 @@ func MvnCmd(c *cli.Context) (err error) { if !xrayScan && format != "" { return cliutils.PrintHelpAndReturnError("The --format option can be sent only with the --scan option", c) } - scanOutputFormat, err := outputFormat.GetOutputFormat(format) - if err != nil { - return err + scanOutputFormat := outputFormat.Table + if format != "" { + scanOutputFormat, err = outputFormat.ParseOutputFormat(format, outputFormat.All) + if err != nil { + return err + } } mvnCmd := mvn.NewMvnCommand().SetConfiguration(buildConfiguration).SetConfigPath(configFilePath).SetGoals(filteredMavenArgs).SetThreads(threads).SetInsecureTls(insecureTls).SetDetailedSummary(detailedSummary || printDeploymentView).SetXrayScan(xrayScan).SetScanOutputFormat(scanOutputFormat) err = commands.Exec(mvnCmd) @@ -813,9 +816,14 @@ func GradleCmd(c *cli.Context) (err error) { if !xrayScan && format != "" { return cliutils.PrintHelpAndReturnError("The --format option can be sent only with the --scan option", c) } - scanOutputFormat, err := outputFormat.GetOutputFormat(format) - if err != nil { - return err + var scanOutputFormat outputFormat.OutputFormat + if format == "" { + scanOutputFormat = outputFormat.Table + } else { + scanOutputFormat, err = outputFormat.ParseOutputFormat(format, outputFormat.All) + if err != nil { + return err + } } printDeploymentView := log.IsStdErrTerminal() gradleCmd := gradle.NewGradleCommand().SetConfiguration(buildConfiguration).SetTasks(filteredGradleArgs).SetConfigPath(configFilePath).SetThreads(threads).SetDetailedSummary(detailedSummary || printDeploymentView).SetXrayScan(xrayScan).SetScanOutputFormat(scanOutputFormat) diff --git a/config/cli.go b/config/cli.go index a99a3b7d9..0692d6a92 100644 --- a/config/cli.go +++ b/config/cli.go @@ -1,23 +1,32 @@ package config import ( + "encoding/json" "fmt" + "io" "os" + "slices" "strings" + "text/tabwriter" "github.com/jfrog/jfrog-cli-core/v2/general/token" "github.com/jfrog/jfrog-client-go/auth/cert" "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli/docs/config/add" "github.com/jfrog/jfrog-cli/docs/config/edit" "github.com/jfrog/jfrog-cli/docs/config/remove" "github.com/jfrog/jfrog-cli/docs/config/use" + clientUtils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" "github.com/jfrog/jfrog-cli/docs/config/exportcmd" @@ -48,6 +57,7 @@ func GetCommands() []cli.Command { Name: "show", Aliases: []string{"s"}, Usage: show.GetDescription(), + Flags: cliutils.GetCommandFlags(cliutils.ConfigShow), HelpName: corecommon.CreateUsage("c show", show.GetDescription(), show.Usage), BashComplete: corecommon.CreateBashCompletionFunc(commands.GetAllServerIds()...), Action: showCmd, @@ -178,7 +188,119 @@ func showCmd(c *cli.Context) error { if c.NArg() == 1 { serverId = c.Args()[0] } - return commands.ShowConfig(serverId) + + if !c.IsSet(cliutils.Format) { + // No format flag — use existing behavior unchanged. + return commands.ShowConfig(serverId) + } + + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Table) + if err != nil { + return err + } + + // Read config directly so we can render it. + var configs []*coreconfig.ServerDetails + if serverId != "" { + single, err := coreconfig.GetSpecificConfig(serverId, false, false) + if err != nil { + return err + } + configs = []*coreconfig.ServerDetails{single} + } else { + configs, err = coreconfig.GetAllServersConfigs() + if err != nil { + return err + } + } + return printConfigShowResponse(configs, outputFormat, os.Stdout) +} + +func printConfigShowResponse(configs []*coreconfig.ServerDetails, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + return printConfigShowJSON(configs) + case coreformat.Table: + return printConfigShowTable(configs, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for config show. Accepted values: table, json", outputFormat) + } +} + +// sanitizeServerDetails returns a copy with sensitive fields masked. +func sanitizeServerDetails(d *coreconfig.ServerDetails) coreconfig.ServerDetails { + s := *d + if s.Password != "" { + s.Password = "***" + } + if s.SshPassphrase != "" { + s.SshPassphrase = "***" + } + if s.AccessToken != "" { + s.AccessToken = "***" + } + if s.RefreshToken != "" { + s.RefreshToken = "***" + } + if s.ArtifactoryRefreshToken != "" { + s.ArtifactoryRefreshToken = "***" + } + return s +} + +func printConfigShowJSON(configs []*coreconfig.ServerDetails) error { + sanitized := make([]coreconfig.ServerDetails, len(configs)) + for i, c := range configs { + sanitized[i] = sanitizeServerDetails(c) + } + data, err := json.Marshal(sanitized) // #nosec G117 + if err != nil { + return errorutils.CheckErrorf("failed to marshal config: %s", err.Error()) + } + log.Output(clientUtils.IndentJson(data)) + return nil +} + +func printConfigShowTable(configs []*coreconfig.ServerDetails, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for i, d := range configs { + if i > 0 { + _, _ = fmt.Fprintln(tw, "---") + } + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + printConfigRow := func(field, value string) { + if value != "" { + _, _ = fmt.Fprintf(tw, "%s\t%s\n", field, value) + } + } + printConfigRow("server_id", d.ServerId) + printConfigRow("url", d.Url) + printConfigRow("artifactory_url", d.ArtifactoryUrl) + printConfigRow("distribution_url", d.DistributionUrl) + printConfigRow("xray_url", d.XrayUrl) + printConfigRow("mission_control_url", d.MissionControlUrl) + printConfigRow("pipelines_url", d.PipelinesUrl) + printConfigRow("user", d.User) + if d.Password != "" { + printConfigRow("password", "***") + } + if d.AccessToken != "" { + printConfigRow("access_token", "***") + } + if d.RefreshToken != "" { + printConfigRow("refresh_token", "***") + } + printConfigRow("ssh_key_path", d.SshKeyPath) + if d.SshPassphrase != "" { + printConfigRow("ssh_passphrase", "***") + } + printConfigRow("client_cert_path", d.ClientCertPath) + printConfigRow("client_cert_key_path", d.ClientCertKeyPath) + if d.IsDefault { + printConfigRow("is_default", "true") + } + } + return tw.Flush() } func deleteCmd(c *cli.Context) error { @@ -243,10 +365,8 @@ func CreateConfigCommandConfiguration(c *cli.Context) (configCommandConfiguratio func ValidateServerId(serverId string) error { reservedIds := []string{"delete", "use", "show", "clear"} - for _, reservedId := range reservedIds { - if serverId == reservedId { - return fmt.Errorf("server can't have one of the following IDs: %s\n%s", strings.Join(reservedIds, ", "), cliutils.GetDocumentationMessage()) - } + if slices.Contains(reservedIds, serverId) { + return fmt.Errorf("server can't have one of the following IDs: %s\n%s", strings.Join(reservedIds, ", "), cliutils.GetDocumentationMessage()) } return nil } diff --git a/config/cli_test.go b/config/cli_test.go new file mode 100644 index 000000000..80432198e --- /dev/null +++ b/config/cli_test.go @@ -0,0 +1,142 @@ +package config + +import ( + "bytes" + "testing" + + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// printConfigShowTable +// --------------------------------------------------------------------------- + +func TestPrintConfigShowTable_Basic(t *testing.T) { + configs := []*coreconfig.ServerDetails{ + {ServerId: "my-server", ArtifactoryUrl: "https://acme.jfrog.io/artifactory"}, + } + var buf bytes.Buffer + err := printConfigShowTable(configs, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "FIELD") + assert.Contains(t, out, "VALUE") + assert.Contains(t, out, "server_id") + assert.Contains(t, out, "my-server") + assert.Contains(t, out, "artifactory_url") + assert.Contains(t, out, "https://acme.jfrog.io/artifactory") +} + +func TestPrintConfigShowTable_MasksSensitiveFields(t *testing.T) { + configs := []*coreconfig.ServerDetails{ // #nosec G101 + { // #nosec G101 + ServerId: "masked", + Password: "secret-password", + AccessToken: "secret-token", + RefreshToken: "secret-refresh", + SshPassphrase: "secret-passphrase", + ArtifactoryRefreshToken: "secret-art-refresh", + }, + } + var buf bytes.Buffer + err := printConfigShowTable(configs, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "***") + assert.NotContains(t, out, "secret-password") + assert.NotContains(t, out, "secret-token") + assert.NotContains(t, out, "secret-refresh") + assert.NotContains(t, out, "secret-passphrase") +} + +func TestPrintConfigShowTable_OmitsEmptyFields(t *testing.T) { + configs := []*coreconfig.ServerDetails{ + {ServerId: "only-id"}, + } + var buf bytes.Buffer + err := printConfigShowTable(configs, &buf) + require.NoError(t, err) + out := buf.String() + // Fields that are not set should not appear. + assert.NotContains(t, out, "artifactory_url") + assert.NotContains(t, out, "xray_url") + assert.NotContains(t, out, "user") + assert.NotContains(t, out, "password") + assert.NotContains(t, out, "access_token") + assert.Contains(t, out, "only-id") +} + +func TestPrintConfigShowTable_MultipleConfigs(t *testing.T) { + configs := []*coreconfig.ServerDetails{ + {ServerId: "server-one"}, + {ServerId: "server-two"}, + } + var buf bytes.Buffer + err := printConfigShowTable(configs, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "---") + assert.Contains(t, out, "server-one") + assert.Contains(t, out, "server-two") +} + +// --------------------------------------------------------------------------- +// printConfigShowJSON +// --------------------------------------------------------------------------- + +func TestPrintConfigShowJSON_NoError(t *testing.T) { + configs := []*coreconfig.ServerDetails{ + {ServerId: "json-server", ArtifactoryUrl: "https://acme.jfrog.io/artifactory"}, + {ServerId: "json-server-2"}, + } + // JSON is written to log.Output, not to w — just verify no error is returned. + err := printConfigShowJSON(configs) + assert.NoError(t, err) +} + +// --------------------------------------------------------------------------- +// printConfigShowResponse +// --------------------------------------------------------------------------- + +func TestPrintConfigShowResponse_UnsupportedFormat(t *testing.T) { + configs := []*coreconfig.ServerDetails{{ServerId: "test"}} + var buf bytes.Buffer + err := printConfigShowResponse(configs, coreformat.Sarif, &buf) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +// --------------------------------------------------------------------------- +// sanitizeServerDetails +// --------------------------------------------------------------------------- + +func TestSanitizeServerDetails(t *testing.T) { + original := &coreconfig.ServerDetails{ + ServerId: "my-server", + Password: "real-password", + SshPassphrase: "real-passphrase", + AccessToken: "real-access-token", + RefreshToken: "real-refresh-token", + ArtifactoryRefreshToken: "real-art-refresh-token", + User: "admin", + } + sanitized := sanitizeServerDetails(original) + + // Sensitive fields must be masked. + assert.Equal(t, "***", sanitized.Password) + assert.Equal(t, "***", sanitized.SshPassphrase) + assert.Equal(t, "***", sanitized.AccessToken) + assert.Equal(t, "***", sanitized.RefreshToken) + assert.Equal(t, "***", sanitized.ArtifactoryRefreshToken) + + // Non-sensitive fields must be preserved. + assert.Equal(t, "my-server", sanitized.ServerId) + assert.Equal(t, "admin", sanitized.User) + + // Original must be unchanged. + assert.Equal(t, "real-password", original.Password) + assert.Equal(t, "real-access-token", original.AccessToken) +} diff --git a/docker_test.go b/docker_test.go index 8d9201830..d46190963 100644 --- a/docker_test.go +++ b/docker_test.go @@ -340,6 +340,7 @@ func buildBuilderImage(t *testing.T, workspace, dockerfile, containerName string // The way we build the fat manifest and push it to Artifactory is not important. // Therefore, this test runs only with docker. func TestPushFatManifestImage(t *testing.T) { + t.Skip("Skipping until JGC-483 is fixed") if !*tests.TestDocker { t.Skip("Skipping test. To run it, add the '-test.docker=true' option.") } @@ -489,6 +490,7 @@ CMD ["echo", "Hello from nested path"]`, baseImage) // TestDockerBuildPushWithNestedPath tests docker build --push with nested paths like repo/myorg/image. // This validates that layer fetching works correctly for single platform images with nested paths. func TestDockerBuildPushWithNestedPath(t *testing.T) { + t.Skip("Skipping until JGC-483 is fixed") cleanup := initDockerBuildTest(t) defer cleanup() runNestedPathDockerBuildTest(t, "docker-build-nested", "test-single-nested", "myorg", "") diff --git a/general/token/cli.go b/general/token/cli.go index 0d2138d5d..fcb3e8c1e 100644 --- a/general/token/cli.go +++ b/general/token/cli.go @@ -1,18 +1,23 @@ package token import ( + "encoding/json" "errors" "fmt" + "io" "os" "strconv" + "text/tabwriter" commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" generic "github.com/jfrog/jfrog-cli-core/v2/general/token" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli/utils/accesstoken" "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-client-go/auth" clientUtils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/log" @@ -61,13 +66,59 @@ func AccessTokenCreateCmd(c *cli.Context) error { if err != nil { return err } - resString, err := accessTokenCreateCmd.Response() + resBytes, err := accessTokenCreateCmd.Response() if err != nil { return err } - log.Output(clientUtils.IndentJson(resString)) + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } + return printTokenResponse(resBytes, outputFormat, os.Stdout) +} - return nil +// printTokenResponse writes the token response in the requested format to w. +func printTokenResponse(data []byte, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + log.Output(clientUtils.IndentJson(data)) + return nil + case coreformat.Table: + return printTokenTable(data, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for access-token-create. Accepted values: table, json", outputFormat) + } +} + +// printTokenTable renders the token fields as a plain two-column table. +// The access_token value is truncated to avoid flooding the terminal. +func printTokenTable(data []byte, w io.Writer) error { + var fields map[string]interface{} + if err := json.Unmarshal(data, &fields); err != nil { + return errorutils.CheckErrorf("failed to parse token response: %s", err.Error()) + } + + // Print fields in a stable, human-friendly order; skip absent/nil ones. + orderedKeys := []string{ + "access_token", "token_id", "expires_in", "scope", + "token_type", "refreshable", "refresh_token", "reference_token", + "grant_type", "audience", + } + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + for _, key := range orderedKeys { + val, ok := fields[key] + if !ok || val == nil { + continue + } + strVal := fmt.Sprintf("%v", val) + if key == "access_token" && len(strVal) > 40 { + strVal = strVal[:40] + "..." + } + _, _ = fmt.Fprintf(tw, "%s\t%s\n", key, strVal) + } + return tw.Flush() } func ExchangeOidcTokenCmd(c *cli.Context) error { @@ -75,6 +126,11 @@ func ExchangeOidcTokenCmd(c *cli.Context) error { return cliutils.WrongNumberOfArgumentsHandler(c) } + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } + serverDetails, err := createPlatformDetailsByFlags(c) if err != nil { return err @@ -88,10 +144,55 @@ func ExchangeOidcTokenCmd(c *cli.Context) error { if err = commands.ExecAndThenReportUsage(oidcAccessTokenCreateCmd); err != nil { return err } - // Print to the console only if the specific EOT command has been executed. - // Otherwise, it will be printed by the config command, which is unnecessary. - oidcAccessTokenCreateCmd.PrintResponseToConsole() - return nil + + return printOidcTokenResponse(oidcAccessTokenCreateCmd.Response(), outputFormat, os.Stdout) +} + +// printOidcTokenResponse writes the OIDC exchange result in the requested format to w. +func printOidcTokenResponse(response *auth.OidcTokenResponseData, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + data, err := json.Marshal(generic.ExchangeCommandOutputStruct{ // #nosec G117 -- intentional output of OIDC exchange result + AccessToken: response.AccessToken, + Username: response.Username, + }) + if err != nil { + return errorutils.CheckErrorf("failed to marshal OIDC token response: %s", err.Error()) + } + log.Output(clientUtils.IndentJson(data)) + return nil + case coreformat.Table: + return printOidcTokenTable(response, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for exchange-oidc-token. Accepted values: table, json", outputFormat) + } +} + +// printOidcTokenTable renders the OIDC token fields as a plain two-column table. +// The access token value is truncated to avoid flooding the terminal. +func printOidcTokenTable(response *auth.OidcTokenResponseData, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + if response.AccessToken != "" { + val := response.AccessToken + if len(val) > 40 { + val = val[:40] + "..." + } + _, _ = fmt.Fprintf(tw, "access_token\t%s\n", val) + } + if response.Username != "" { + _, _ = fmt.Fprintf(tw, "username\t%s\n", response.Username) + } + if response.IssuedTokenType != "" { + _, _ = fmt.Fprintf(tw, "issued_token_type\t%s\n", response.IssuedTokenType) + } + if response.Scope != "" { + _, _ = fmt.Fprintf(tw, "scope\t%s\n", response.Scope) + } + if response.TokenType != "" { + _, _ = fmt.Fprintf(tw, "token_type\t%s\n", response.TokenType) + } + return tw.Flush() } func CreateOidcTokenExchangeCommand(c *cli.Context, serverDetails *coreConfig.ServerDetails) (*generic.OidcTokenExchangeCommand, error) { diff --git a/general/token/cli_test.go b/general/token/cli_test.go new file mode 100644 index 000000000..13a9b627f --- /dev/null +++ b/general/token/cli_test.go @@ -0,0 +1,184 @@ +package token + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-client-go/auth" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleTokenResponse returns a minimal but realistic token JSON payload. +func sampleTokenResponse(t *testing.T) []byte { + t.Helper() + payload := map[string]interface{}{ // #nosec G101 + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkV4YW1wbGUifQ.payload.signature", + "token_id": "abc123-def456", + "expires_in": 3600, + "scope": "applied-permissions/user", + "token_type": "Bearer", + "refreshable": false, + } + data, err := json.Marshal(payload) + require.NoError(t, err) + return data +} + +func TestPrintTokenResponse_JSON(t *testing.T) { + data := sampleTokenResponse(t) + + var buf bytes.Buffer + err := printTokenResponse(data, coreformat.Json, &buf) + require.NoError(t, err) + + // JSON format uses log.Output (writes to the real logger, not the writer). + // The buf should remain empty; validate JSON is well-formed instead. + var parsed map[string]interface{} + require.NoError(t, json.Unmarshal(data, &parsed)) + assert.Equal(t, "Bearer", parsed["token_type"]) + assert.Equal(t, "abc123-def456", parsed["token_id"]) +} + +func TestPrintTokenResponse_Table(t *testing.T) { + data := sampleTokenResponse(t) + + var buf bytes.Buffer + err := printTokenResponse(data, coreformat.Table, &buf) + require.NoError(t, err) + + output := buf.String() + + // Header row must be present. + assert.Contains(t, output, "FIELD") + assert.Contains(t, output, "VALUE") + + // Known fields must appear. + assert.Contains(t, output, "token_id") + assert.Contains(t, output, "abc123-def456") + assert.Contains(t, output, "expires_in") + assert.Contains(t, output, "scope") + assert.Contains(t, output, "applied-permissions/user") + assert.Contains(t, output, "token_type") + assert.Contains(t, output, "Bearer") +} + +func TestPrintTokenResponse_Table_AccessTokenTruncated(t *testing.T) { + longToken := strings.Repeat("a", 100) + payload := map[string]interface{}{"access_token": longToken} + data, err := json.Marshal(payload) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, printTokenResponse(data, coreformat.Table, &buf)) + + output := buf.String() + assert.Contains(t, output, "access_token") + // Full token must NOT appear; truncated version (40 chars + "...") must. + assert.NotContains(t, output, longToken) + assert.Contains(t, output, strings.Repeat("a", 40)+"...") +} + +func TestPrintTokenResponse_Table_AbsentFieldsOmitted(t *testing.T) { + // Payload with only access_token; all other fields must be absent from table. + payload := map[string]interface{}{ + "access_token": "tok", + } + data, err := json.Marshal(payload) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, printTokenResponse(data, coreformat.Table, &buf)) + + output := buf.String() + assert.NotContains(t, output, "token_id") + assert.NotContains(t, output, "expires_in") + assert.NotContains(t, output, "scope") +} + +func TestPrintTokenResponse_UnsupportedFormat(t *testing.T) { + err := printTokenResponse([]byte("{}"), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +// --- exchange-oidc-token tests --- + +func sampleOidcResponse() *auth.OidcTokenResponseData { + scope := "applied-permissions/user" + return &auth.OidcTokenResponseData{ // #nosec G101 + CommonTokenParams: auth.CommonTokenParams{ // #nosec G101 + AccessToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.payload.sig", + Scope: scope, + TokenType: "Bearer", + }, + IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token", + Username: "ci-user", + } +} + +func TestPrintOidcTokenResponse_JSON(t *testing.T) { + var buf bytes.Buffer + err := printOidcTokenResponse(sampleOidcResponse(), coreformat.Json, &buf) + require.NoError(t, err) + + // JSON goes through log.Output; validate the source struct is well-formed. + resp := sampleOidcResponse() + assert.Equal(t, "Bearer", resp.TokenType) + assert.Equal(t, "ci-user", resp.Username) +} + +func TestPrintOidcTokenResponse_Table(t *testing.T) { + var buf bytes.Buffer + err := printOidcTokenResponse(sampleOidcResponse(), coreformat.Table, &buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "FIELD") + assert.Contains(t, output, "VALUE") + assert.Contains(t, output, "access_token") + assert.Contains(t, output, "username") + assert.Contains(t, output, "ci-user") + assert.Contains(t, output, "token_type") + assert.Contains(t, output, "Bearer") + assert.Contains(t, output, "scope") + assert.Contains(t, output, "applied-permissions/user") +} + +func TestPrintOidcTokenResponse_Table_AccessTokenTruncated(t *testing.T) { + longToken := strings.Repeat("z", 100) + resp := &auth.OidcTokenResponseData{ + CommonTokenParams: auth.CommonTokenParams{AccessToken: longToken}, + } + + var buf bytes.Buffer + require.NoError(t, printOidcTokenResponse(resp, coreformat.Table, &buf)) + + output := buf.String() + assert.NotContains(t, output, longToken) + assert.Contains(t, output, strings.Repeat("z", 40)+"...") +} + +func TestPrintOidcTokenResponse_Table_AbsentFieldsOmitted(t *testing.T) { + resp := &auth.OidcTokenResponseData{ + CommonTokenParams: auth.CommonTokenParams{AccessToken: "tok"}, + } + + var buf bytes.Buffer + require.NoError(t, printOidcTokenResponse(resp, coreformat.Table, &buf)) + + output := buf.String() + assert.NotContains(t, output, "username") + assert.NotContains(t, output, "scope") + assert.NotContains(t, output, "issued_token_type") +} + +func TestPrintOidcTokenResponse_UnsupportedFormat(t *testing.T) { + err := printOidcTokenResponse(sampleOidcResponse(), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + diff --git a/go.mod b/go.mod index 645fa55ec..78e4089af 100644 --- a/go.mod +++ b/go.mod @@ -20,9 +20,9 @@ require ( github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-application v1.0.2-0.20260405065840-c930d515ef34 github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260429074430-a5871f2898b5 - github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260429073430-5723b4f21705 + github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260430125911-ad12ac6f1316 github.com/jfrog/jfrog-cli-evidence v0.9.2 - github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260423120557-be568d9dd4c2 + github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260430094150-ce7d9b371c6f github.com/jfrog/jfrog-cli-security v1.28.0 github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7 github.com/jszwec/csvutil v1.10.0 diff --git a/go.sum b/go.sum index 8a2d7aead..a227a9f55 100644 --- a/go.sum +++ b/go.sum @@ -420,12 +420,14 @@ github.com/jfrog/jfrog-cli-application v1.0.2-0.20260405065840-c930d515ef34 h1:q github.com/jfrog/jfrog-cli-application v1.0.2-0.20260405065840-c930d515ef34/go.mod h1:xum2HquWO5uExa/A7MQs3TgJJVEeoqTR+6Z4mfBr1Xw= github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260429074430-a5871f2898b5 h1:+52DDmdSZFP1dxgeu0pkB1sQuoHa0PWbW7HVdFOqK3A= github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260429074430-a5871f2898b5/go.mod h1:BV+aCTQsaZeFec2WjgmQjqlxecju4CkkM9NqfiFyjo0= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260429073430-5723b4f21705 h1:J0VjEWHN6iXIWthphaKVakly7ONsN5GyoumMhP/m6yc= -github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260429073430-5723b4f21705/go.mod h1:bjAkVD8c2W+jg4whqy10bSXDC/c+Se8/ll/GPp5F/+0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260430091103-6242ecf15d29 h1:J5+08rOpv/avgt53jNFZ+j5gU8mllcj7Dcfja5Ewodw= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260430091103-6242ecf15d29/go.mod h1:bjAkVD8c2W+jg4whqy10bSXDC/c+Se8/ll/GPp5F/+0= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260430125911-ad12ac6f1316 h1:xAl5D+SjLeRH1gCsSHFPpXJeQQBv2HDGqDTDkFOKJ2s= +github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260430125911-ad12ac6f1316/go.mod h1:bjAkVD8c2W+jg4whqy10bSXDC/c+Se8/ll/GPp5F/+0= github.com/jfrog/jfrog-cli-evidence v0.9.2 h1:huiBzQSI9z3OF3l2RphthdXl1aH9zBsvAt+zLsApORI= github.com/jfrog/jfrog-cli-evidence v0.9.2/go.mod h1:R9faPfyQESBmKrdZCmHvlpmYSHmffswjNnFeT3RMq8I= -github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260423120557-be568d9dd4c2 h1:j6X2WkGE/lnkZSZ3YdhBc2EBoXXo/ijxpZ2qKWgSqxE= -github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260423120557-be568d9dd4c2/go.mod h1:n18milUWaaWv6o0bWeEDuoweACoZHRTtQPZpyXBPjEs= +github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260430094150-ce7d9b371c6f h1:M1cesbKYSznwPA76dNctjCELxGx34TSSjwoYnJm9/6Y= +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.28.0 h1:A/xxwbnjCfQOGT8LqtjYsPXmGg2kpcjlABw2DMFgId8= github.com/jfrog/jfrog-cli-security v1.28.0/go.mod h1:+eO5IgCkfiz0/8fCf6UwBg1KWXoDbOQ/E2V8DR9Ziq8= github.com/jfrog/jfrog-client-go v1.55.1-0.20260428070955-750b933dc5c7 h1:MvHnFczVntYB/USj7/RRANvdWbTUcwEvXcIGr7lOyTc= diff --git a/missioncontrol/cli.go b/missioncontrol/cli.go index d086d1832..e04067d09 100644 --- a/missioncontrol/cli.go +++ b/missioncontrol/cli.go @@ -1,10 +1,17 @@ package missioncontrol import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" "strconv" + "text/tabwriter" commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" coreCommonCommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" "github.com/jfrog/jfrog-cli-core/v2/missioncontrol/commands" "github.com/jfrog/jfrog-cli-core/v2/utils/config" @@ -18,6 +25,7 @@ import ( clientutils "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" ) @@ -85,11 +93,61 @@ func jpdAdd(c *cli.Context) error { if len(c.Args()) != 1 { return cliutils.WrongNumberOfArgumentsHandler(c) } + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } jpdAddFlags, err := createJpdAddFlags(c) if err != nil { return err } - return commands.JpdAdd(jpdAddFlags) + body, err := commands.JpdAdd(jpdAddFlags) + if err != nil { + return err + } + return printJpdAddResponse(body, outputFormat, os.Stdout) +} + +func printJpdAddResponse(body []byte, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + log.Output(clientutils.IndentJson(body)) + return nil + case coreformat.Table: + return printJpdAddTable(body, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for jpd-add. Accepted values: table, json", outputFormat) + } +} + +func printJpdAddTable(body []byte, w io.Writer) error { + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return errorutils.CheckErrorf("failed to parse jpd-add response: %s", err.Error()) + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + orderedKeys := []string{"id", "name", "url", "location", "licenses", "type"} + printed := map[string]bool{} + for _, k := range orderedKeys { + if v, ok := data[k]; ok && v != nil { + _, _ = fmt.Fprintf(tw, "%s\t%v\n", k, v) + printed[k] = true + } + } + remaining := []string{} + for k := range data { + if !printed[k] { + remaining = append(remaining, k) + } + } + sort.Strings(remaining) + for _, k := range remaining { + if data[k] != nil { + _, _ = fmt.Fprintf(tw, "%s\t%v\n", k, data[k]) + } + } + return tw.Flush() } func jpdDelete(c *cli.Context) error { @@ -108,12 +166,34 @@ func licenseAcquire(c *cli.Context) error { if size != 2 { return cliutils.WrongNumberOfArgumentsHandler(c) } + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Table) + if err != nil { + return err + } mcDetails, err := createMissionControlDetails(c) if err != nil { return err } + licenseKey, err := commands.LicenseAcquire(c.Args()[0], c.Args()[1], mcDetails) + if err != nil { + return err + } + return printLicenseAcquireResponse(licenseKey, outputFormat, os.Stdout) +} - return commands.LicenseAcquire(c.Args()[0], c.Args()[1], mcDetails) +func printLicenseAcquireResponse(licenseKey string, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + log.Output(clientutils.IndentJson([]byte(`{"license_key":"` + licenseKey + `"}`))) + return nil + case coreformat.Table: + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + _, _ = fmt.Fprintf(tw, "license_key\t%s\n", licenseKey) + return tw.Flush() + default: + return errorutils.CheckErrorf("unsupported format '%s' for license-acquire. Accepted values: table, json", outputFormat) + } } func licenseDeploy(c *cli.Context) error { @@ -121,11 +201,61 @@ func licenseDeploy(c *cli.Context) error { if size != 2 { return cliutils.WrongNumberOfArgumentsHandler(c) } + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Json) + if err != nil { + return err + } flags, err := createLicenseDeployFlags(c) if err != nil { return err } - return commands.LicenseDeploy(c.Args()[0], c.Args()[1], flags) + body, err := commands.LicenseDeploy(c.Args()[0], c.Args()[1], flags) + if err != nil { + return err + } + return printLicenseDeployResponse(body, outputFormat, os.Stdout) +} + +func printLicenseDeployResponse(body []byte, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + log.Output(clientutils.IndentJson(body)) + return nil + case coreformat.Table: + return printLicenseDeployTable(body, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for license-deploy. Accepted values: table, json", outputFormat) + } +} + +func printLicenseDeployTable(body []byte, w io.Writer) error { + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + return errorutils.CheckErrorf("failed to parse license-deploy response: %s", err.Error()) + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "FIELD\tVALUE") + orderedKeys := []string{"bucket_id", "jpd_id", "license_count", "status"} + printed := map[string]bool{} + for _, k := range orderedKeys { + if v, ok := data[k]; ok && v != nil { + _, _ = fmt.Fprintf(tw, "%s\t%v\n", k, v) + printed[k] = true + } + } + remaining := []string{} + for k := range data { + if !printed[k] { + remaining = append(remaining, k) + } + } + sort.Strings(remaining) + for _, k := range remaining { + if data[k] != nil { + _, _ = fmt.Fprintf(tw, "%s\t%v\n", k, data[k]) + } + } + return tw.Flush() } func licenseRelease(c *cli.Context) error { diff --git a/missioncontrol/cli_test.go b/missioncontrol/cli_test.go new file mode 100644 index 000000000..d3214c0a1 --- /dev/null +++ b/missioncontrol/cli_test.go @@ -0,0 +1,123 @@ +package missioncontrol + +import ( + "bytes" + "testing" + + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---- jpd-add tests ---- + +func TestPrintJpdAddResponse_JSON(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"id":"jpd1","name":"My JPD","url":"https://my.jfrog.io"}`) + err := printJpdAddResponse(body, coreformat.Json, &buf) + require.NoError(t, err) + // JSON goes via log.Output; verify no error and no table output. + assert.Empty(t, buf.String()) +} + +func TestPrintJpdAddResponse_Table(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"id":"jpd1","name":"My JPD","url":"https://my.jfrog.io","location":"US","type":"EDGE"}`) + err := printJpdAddResponse(body, coreformat.Table, &buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "FIELD") + assert.Contains(t, output, "VALUE") + assert.Contains(t, output, "id") + assert.Contains(t, output, "jpd1") + assert.Contains(t, output, "name") + assert.Contains(t, output, "My JPD") +} + +func TestPrintJpdAddResponse_Table_UnknownKeys(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"custom_field":"custom_value","another_field":"another_value"}`) + err := printJpdAddResponse(body, coreformat.Table, &buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "custom_field") + assert.Contains(t, output, "custom_value") + assert.Contains(t, output, "another_field") + assert.Contains(t, output, "another_value") +} + +func TestPrintJpdAddResponse_UnsupportedFormat(t *testing.T) { + err := printJpdAddResponse([]byte(`{}`), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +// ---- license-deploy tests ---- + +func TestPrintLicenseDeployResponse_JSON(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"bucket_id":"bucket1","jpd_id":"jpd1","license_count":5}`) + err := printLicenseDeployResponse(body, coreformat.Json, &buf) + require.NoError(t, err) + // JSON goes via log.Output; verify no error and no table output. + assert.Empty(t, buf.String()) +} + +func TestPrintLicenseDeployResponse_Table(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"bucket_id":"bucket1","jpd_id":"jpd1","license_count":5,"status":"deployed"}`) + err := printLicenseDeployResponse(body, coreformat.Table, &buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "FIELD") + assert.Contains(t, output, "VALUE") + assert.Contains(t, output, "bucket_id") + assert.Contains(t, output, "bucket1") + assert.Contains(t, output, "jpd_id") + assert.Contains(t, output, "jpd1") +} + +func TestPrintLicenseDeployResponse_Table_UnknownKeys(t *testing.T) { + var buf bytes.Buffer + body := []byte(`{"extra_field":"extra_value","another_field":"another_value"}`) + err := printLicenseDeployResponse(body, coreformat.Table, &buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "extra_field") + assert.Contains(t, output, "extra_value") + assert.Contains(t, output, "another_field") + assert.Contains(t, output, "another_value") +} + +func TestPrintLicenseDeployResponse_UnsupportedFormat(t *testing.T) { + err := printLicenseDeployResponse([]byte(`{}`), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +func TestPrintLicenseAcquireResponse_Table(t *testing.T) { + var buf bytes.Buffer + err := printLicenseAcquireResponse("ABCD-1234-EFGH-5678", coreformat.Table, &buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "FIELD") + assert.Contains(t, output, "VALUE") + assert.Contains(t, output, "license_key") + assert.Contains(t, output, "ABCD-1234-EFGH-5678") +} + +func TestPrintLicenseAcquireResponse_JSON(t *testing.T) { + var buf bytes.Buffer + err := printLicenseAcquireResponse("ABCD-1234-EFGH-5678", coreformat.Json, &buf) + require.NoError(t, err) + // JSON goes via log.Output; verify no error and no table output. + assert.Empty(t, buf.String()) +} + +func TestPrintLicenseAcquireResponse_UnsupportedFormat(t *testing.T) { + err := printLicenseAcquireResponse("key", coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + diff --git a/pipelines/cli.go b/pipelines/cli.go index 347508672..48dcc0c48 100644 --- a/pipelines/cli.go +++ b/pipelines/cli.go @@ -1,10 +1,18 @@ package pipelines import ( + "encoding/json" "errors" + "fmt" + "io" + "os" + "text/tabwriter" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" pipelines "github.com/jfrog/jfrog-cli-core/v2/pipelines/commands" + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-cli/docs/common" @@ -13,9 +21,11 @@ import ( "github.com/jfrog/jfrog-cli/docs/pipelines/syncstatus" "github.com/jfrog/jfrog-cli/docs/pipelines/trigger" "github.com/jfrog/jfrog-cli/docs/pipelines/version" - "github.com/jfrog/jfrog-cli/utils/cliutils" + clientUtils "github.com/jfrog/jfrog-client-go/utils" clientlog "github.com/jfrog/jfrog-client-go/utils/log" + plservices "github.com/jfrog/jfrog-client-go/pipelines/services" + "github.com/jfrog/jfrog-client-go/utils/errorutils" "github.com/urfave/cli" ) @@ -96,7 +106,6 @@ func createPipelinesDetailsByFlags(c *cli.Context) (*coreConfig.ServerDetails, e func fetchLatestPipelineRunStatus(c *cli.Context) error { clientlog.Info(coreutils.PrintTitle("Fetching pipeline run status")) - // Read flags for status command pipName := c.String("pipeline-name") notify := c.Bool("monitor") branch := c.String("branch") @@ -105,19 +114,77 @@ func fetchLatestPipelineRunStatus(c *cli.Context) error { if err != nil { return err } + + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Table) + if err != nil { + return err + } + statusCommand := pipelines.NewStatusCommand() statusCommand.SetBranch(branch). SetPipeline(pipName). SetNotify(notify). - SetMultiBranch(multiBranch) + SetMultiBranch(multiBranch). + SetServerDetails(serviceDetails) + + if c.IsSet(cliutils.Format) { + statusCommand.SetSuppressOutput(true) + } + + if err = commands.Exec(statusCommand); err != nil { + return err + } - // Set server details - statusCommand.SetServerDetails(serviceDetails) - return commands.Exec(statusCommand) + if !c.IsSet(cliutils.Format) { + return nil + } + return printPipelineStatusResponse(statusCommand.Response(), outputFormat, os.Stdout) +} + +func printPipelineStatusResponse(resp *plservices.PipelineRunStatusResponse, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + data, err := json.Marshal(resp) + if err != nil { + return errorutils.CheckErrorf("failed to marshal pipeline status response: %s", err.Error()) + } + clientlog.Output(clientUtils.IndentJson(data)) + return nil + case coreformat.Table: + return printPipelineStatusTable(resp, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for pl status. Accepted values: table, json", outputFormat) + } +} + +func printPipelineStatusTable(resp *plservices.PipelineRunStatusResponse, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "PIPELINE\tBRANCH\tRUN\tSTATUS\tDURATION") + for _, pipe := range resp.Pipelines { + if pipe.LatestRunID == 0 { + continue + } + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%ds\n", + pipe.Name, + pipe.PipelineSourceBranch, + pipe.Run.RunNumber, + pipe.Run.StatusCode, + pipe.Run.DurationSeconds, + ) + } + return tw.Flush() } // syncPipelineResources sync pipelines resource func syncPipelineResources(c *cli.Context) error { + requireStructuredOutput := c.IsSet(cliutils.Format) + + if requireStructuredOutput { + if _, fmtErr := coreformat.ParseOutputFormat(c.String(cliutils.Format), []coreformat.OutputFormat{coreformat.Json}); fmtErr != nil { + return fmtErr + } + } + // Get arguments repository name and branch name repository := c.Args().Get(0) branch := c.Args().Get(1) @@ -132,7 +199,18 @@ func syncPipelineResources(c *cli.Context) error { syncCommand.SetBranch(branch) syncCommand.SetRepositoryFullName(repository) syncCommand.SetServerDetails(serviceDetails) - return commands.Exec(syncCommand) + + if err = commands.Exec(syncCommand); err != nil { + return err + } + + // error == nil guarantees the server responded with 200. + // The client layer discards the body, so we pass nil and let the helper + // synthesize {"status_code": 200, "message": "OK"}. + if requireStructuredOutput { + cliutils.FormatHTTPResponseJSON(nil, 200) + } + return nil } // getSyncPipelineResourcesStatus fetch sync status for a given repository path and branch name @@ -147,6 +225,11 @@ func getSyncPipelineResourcesStatus(c *cli.Context) error { } clientlog.Info("Fetching pipeline sync status on repository:", repository, "branch:", branch) + outputFormat, err := commonCliUtils.GetOutputFormat(c, coreformat.Table) + if err != nil { + return err + } + // Fetch service details for authentication serviceDetails, err := createPipelinesDetailsByFlags(c) if err != nil { @@ -158,7 +241,55 @@ func getSyncPipelineResourcesStatus(c *cli.Context) error { syncStatusCommand.SetBranch(branch) syncStatusCommand.SetRepoPath(repository) syncStatusCommand.SetServerDetails(serviceDetails) - return commands.Exec(syncStatusCommand) + + if c.IsSet(cliutils.Format) { + syncStatusCommand.SetSuppressOutput(true) + } + + if err = commands.Exec(syncStatusCommand); err != nil { + return err + } + + if !c.IsSet(cliutils.Format) { + return nil + } + return printSyncStatusResponse(syncStatusCommand.Response(), outputFormat, os.Stdout) +} + +func printSyncStatusResponse(statuses []plservices.PipelineSyncStatus, outputFormat coreformat.OutputFormat, w io.Writer) error { + switch outputFormat { + case coreformat.Json: + data, err := json.Marshal(statuses) + if err != nil { + return errorutils.CheckErrorf("failed to marshal sync status response: %s", err.Error()) + } + clientlog.Output(clientUtils.IndentJson(data)) + return nil + case coreformat.Table: + return printSyncStatusTable(statuses, w) + default: + return errorutils.CheckErrorf("unsupported format '%s' for pl sync-status. Accepted values: table, json", outputFormat) + } +} + +func printSyncStatusTable(statuses []plservices.PipelineSyncStatus, w io.Writer) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "BRANCH\tSTATUS\tIS_SYNCING\tSTARTED\tENDED\tCOMMIT_SHA") + for _, s := range statuses { + isSyncing := false + if s.IsSyncing != nil { + isSyncing = *s.IsSyncing + } + _, _ = fmt.Fprintf(tw, "%s\t%d\t%t\t%s\t%s\t%s\n", + s.PipelineSourceBranch, + s.LastSyncStatusCode, + isSyncing, + s.LastSyncStartedAt.Format("2006-01-02T15:04:05Z"), + s.LastSyncEndedAt.Format("2006-01-02T15:04:05Z"), + s.CommitData.CommitSha, + ) + } + return tw.Flush() } // getVersion version command handler @@ -174,24 +305,38 @@ func getVersion(c *cli.Context) error { // triggerNewRun triggers a new run for supplied flag values func triggerNewRun(c *cli.Context) error { - // Read arguments pipeline name and branch to trigger pipeline run + if c.IsSet(cliutils.Format) { + if _, fmtErr := coreformat.ParseOutputFormat(c.String(cliutils.Format), []coreformat.OutputFormat{coreformat.Json}); fmtErr != nil { + return fmtErr + } + } + pipelineName := c.Args().Get(0) branch := c.Args().Get(1) multiBranch := getMultiBranch(c) coreutils.PrintTitle("Triggering pipeline run ") clientlog.Info("Triggering on pipeline:", pipelineName, "for branch:", branch) - // Get service config details serviceDetails, err := createPipelinesDetailsByFlags(c) if err != nil { return err } - // Trigger a pipeline run using branch name and pipeline name triggerCommand := pipelines.NewTriggerCommand() triggerCommand.SetBranch(branch). SetPipelineName(pipelineName). SetServerDetails(serviceDetails). SetMultiBranch(multiBranch) - return commands.Exec(triggerCommand) + + if err = commands.Exec(triggerCommand); err != nil { + return err + } + + // error == nil guarantees the server responded with 200. + // The client layer discards the body, so we pass nil and let the helper + // synthesize {"status_code": 200, "message": "OK"}. + if c.IsSet(cliutils.Format) { + cliutils.FormatHTTPResponseJSON(nil, 200) + } + return nil } diff --git a/pipelines/cli_test.go b/pipelines/cli_test.go new file mode 100644 index 000000000..a37a23fa1 --- /dev/null +++ b/pipelines/cli_test.go @@ -0,0 +1,114 @@ +package pipelines + +import ( + "bytes" + "testing" + "time" + + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-client-go/pipelines/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func samplePipelineStatusResponse() *services.PipelineRunStatusResponse { + return &services.PipelineRunStatusResponse{ + TotalCount: 1, + Pipelines: []services.Pipelines{ + { + Name: "my-pipeline", + PipelineSourceBranch: "main", + LatestRunID: 42, + Run: services.Run{ + RunNumber: 7, + StatusCode: 4004, + DurationSeconds: 120, + }, + }, + }, + } +} + +func TestPrintPipelineStatusResponse_Table(t *testing.T) { + var buf bytes.Buffer + err := printPipelineStatusResponse(samplePipelineStatusResponse(), coreformat.Table, &buf) + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "PIPELINE") + assert.Contains(t, output, "BRANCH") + assert.Contains(t, output, "RUN") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "my-pipeline") + assert.Contains(t, output, "main") + assert.Contains(t, output, "7") +} + +func TestPrintPipelineStatusResponse_JSON(t *testing.T) { + var buf bytes.Buffer + err := printPipelineStatusResponse(samplePipelineStatusResponse(), coreformat.Json, &buf) + require.NoError(t, err) + // JSON goes via log.Output; buf remains empty. + assert.Empty(t, buf.String()) +} + +func TestPrintPipelineStatusResponse_Table_SkipsUnrunPipelines(t *testing.T) { + resp := &services.PipelineRunStatusResponse{ + Pipelines: []services.Pipelines{ + {Name: "never-ran", LatestRunID: 0}, + }, + } + var buf bytes.Buffer + require.NoError(t, printPipelineStatusResponse(resp, coreformat.Table, &buf)) + assert.NotContains(t, buf.String(), "never-ran") +} + +func TestPrintPipelineStatusResponse_UnsupportedFormat(t *testing.T) { + err := printPipelineStatusResponse(samplePipelineStatusResponse(), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + +// --- pl sync-status tests --- + +func sampleSyncStatuses() []services.PipelineSyncStatus { + isSyncing := true + return []services.PipelineSyncStatus{ + { + PipelineSourceBranch: "main", + LastSyncStatusCode: 4002, + IsSyncing: &isSyncing, + LastSyncStartedAt: time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC), + LastSyncEndedAt: time.Date(2026, 1, 2, 15, 6, 5, 0, time.UTC), + CommitData: services.CommitData{ + CommitSha: "abc123", + }, + }, + } +} + +func TestPrintSyncStatusResponse_Table(t *testing.T) { + var buf bytes.Buffer + err := printSyncStatusResponse(sampleSyncStatuses(), coreformat.Table, &buf) + require.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "BRANCH") + assert.Contains(t, output, "STATUS") + assert.Contains(t, output, "main") + assert.Contains(t, output, "abc123") +} + +func TestPrintSyncStatusResponse_JSON(t *testing.T) { + var buf bytes.Buffer + err := printSyncStatusResponse(sampleSyncStatuses(), coreformat.Json, &buf) + require.NoError(t, err) + // JSON goes via log.Output; buf remains empty. + assert.Empty(t, buf.String()) +} + +func TestPrintSyncStatusResponse_UnsupportedFormat(t *testing.T) { + err := printSyncStatusResponse(sampleSyncStatuses(), coreformat.Sarif, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported format") +} + diff --git a/plugins/cli.go b/plugins/cli.go index ae1842e4e..39ca4cf03 100644 --- a/plugins/cli.go +++ b/plugins/cli.go @@ -1,6 +1,7 @@ package plugins import ( + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" "github.com/jfrog/jfrog-cli/docs/common" installdocs "github.com/jfrog/jfrog-cli/docs/plugin/install" @@ -11,17 +12,48 @@ import ( "github.com/urfave/cli" ) +func installPlugin(c *cli.Context) error { + if c.IsSet(cliutils.Format) { + if _, fmtErr := coreformat.ParseOutputFormat(c.String(cliutils.Format), []coreformat.OutputFormat{coreformat.Json}); fmtErr != nil { + return fmtErr + } + } + if err := commands.InstallCmd(c); err != nil { + return err + } + if c.IsSet(cliutils.Format) { + cliutils.FormatHTTPResponseJSON(nil, 200) + } + return nil +} + +func publishPlugin(c *cli.Context) error { + if c.IsSet(cliutils.Format) { + if _, fmtErr := coreformat.ParseOutputFormat(c.String(cliutils.Format), []coreformat.OutputFormat{coreformat.Json}); fmtErr != nil { + return fmtErr + } + } + if err := commands.PublishCmd(c); err != nil { + return err + } + if c.IsSet(cliutils.Format) { + cliutils.FormatHTTPResponseJSON(nil, 200) + } + return nil +} + func GetCommands() []cli.Command { return cliutils.GetSortedCommands(cli.CommandsByName{ { Name: "install", Aliases: []string{"i"}, + Flags: cliutils.GetCommandFlags(cliutils.PluginInstall), Usage: installdocs.GetDescription(), HelpName: corecommon.CreateUsage("plugin install", installdocs.GetDescription(), installdocs.Usage), UsageText: installdocs.GetArguments(), ArgsUsage: common.CreateEnvVars(installdocs.EnvVar...), BashComplete: corecommon.CreateBashCompletionFunc(), - Action: commands.InstallCmd, + Action: installPlugin, }, { Name: "uninstall", @@ -36,12 +68,13 @@ func GetCommands() []cli.Command { { Name: "publish", Aliases: []string{"p"}, + Flags: cliutils.GetCommandFlags(cliutils.PluginPublish), Usage: publishdocs.GetDescription(), HelpName: corecommon.CreateUsage("plugin publish", publishdocs.GetDescription(), publishdocs.Usage), UsageText: publishdocs.GetArguments(), ArgsUsage: common.CreateEnvVars(publishdocs.EnvVar...), BashComplete: corecommon.CreateBashCompletionFunc(), - Action: commands.PublishCmd, + Action: publishPlugin, }, }) } diff --git a/plugins/cli_test.go b/plugins/cli_test.go new file mode 100644 index 000000000..24a55bc0a --- /dev/null +++ b/plugins/cli_test.go @@ -0,0 +1,114 @@ +package plugins + +import ( + "fmt" + "testing" + + coreformat "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +// newFormatApp returns a minimal *cli.App with a --format flag for testing the +// format-guard logic in installPlugin / publishPlugin without needing a live server. +func newFormatApp(action cli.ActionFunc) *cli.App { + app := cli.NewApp() + app.Flags = []cli.Flag{cli.StringFlag{Name: "format"}} + app.Action = action + return app +} + +// formatGuard mirrors the format-check block used in both installPlugin and +// publishPlugin so we can test it independently of the HTTP calls. +func formatGuard(c *cli.Context, cmdName string) error { + if !c.IsSet("format") { + return nil + } + outputFormat, err := coreformat.ParseOutputFormat(c.String("format"), coreformat.All) + if err != nil { + return err + } + if outputFormat == coreformat.Json { + // In the real wrappers, FormatHTTPResponseJSON is called here. + // For unit tests we just verify the branch is reached without error. + return nil + } + return fmt.Errorf("unsupported format '%s' for %s. Only json is supported", outputFormat, cmdName) +} + +// --- installPlugin format-guard tests --- + +func TestInstallPlugin_FormatNotSet_NoError(t *testing.T) { + app := newFormatApp(func(c *cli.Context) error { + return formatGuard(c, "plugin install") + }) + require.NoError(t, app.Run([]string{"app"})) +} + +func TestInstallPlugin_FormatJSON_NoError(t *testing.T) { + app := newFormatApp(func(c *cli.Context) error { + return formatGuard(c, "plugin install") + }) + require.NoError(t, app.Run([]string{"app", "--format", "json"})) +} + +func TestInstallPlugin_FormatTable_ReturnsError(t *testing.T) { + var gotErr error + app := newFormatApp(func(c *cli.Context) error { + gotErr = formatGuard(c, "plugin install") + return nil + }) + require.NoError(t, app.Run([]string{"app", "--format", "table"})) + require.Error(t, gotErr) + assert.Contains(t, gotErr.Error(), "unsupported format") + assert.Contains(t, gotErr.Error(), "plugin install") +} + +func TestInstallPlugin_InvalidFormat_ReturnsError(t *testing.T) { + var gotErr error + app := newFormatApp(func(c *cli.Context) error { + gotErr = formatGuard(c, "plugin install") + return nil + }) + require.NoError(t, app.Run([]string{"app", "--format", "xml"})) + require.Error(t, gotErr) +} + +// --- publishPlugin format-guard tests --- + +func TestPublishPlugin_FormatNotSet_NoError(t *testing.T) { + app := newFormatApp(func(c *cli.Context) error { + return formatGuard(c, "plugin publish") + }) + require.NoError(t, app.Run([]string{"app"})) +} + +func TestPublishPlugin_FormatJSON_NoError(t *testing.T) { + app := newFormatApp(func(c *cli.Context) error { + return formatGuard(c, "plugin publish") + }) + require.NoError(t, app.Run([]string{"app", "--format", "json"})) +} + +func TestPublishPlugin_FormatTable_ReturnsError(t *testing.T) { + var gotErr error + app := newFormatApp(func(c *cli.Context) error { + gotErr = formatGuard(c, "plugin publish") + return nil + }) + require.NoError(t, app.Run([]string{"app", "--format", "table"})) + require.Error(t, gotErr) + assert.Contains(t, gotErr.Error(), "unsupported format") + assert.Contains(t, gotErr.Error(), "plugin publish") +} + +func TestPublishPlugin_InvalidFormat_ReturnsError(t *testing.T) { + var gotErr error + app := newFormatApp(func(c *cli.Context) error { + gotErr = formatGuard(c, "plugin publish") + return nil + }) + require.NoError(t, app.Run([]string{"app", "--format", "xml"})) + require.Error(t, gotErr) +} diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 1b8b28496..60e5c9a8c 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -8,6 +8,8 @@ import ( "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + "github.com/jfrog/jfrog-cli-core/v2/common/format" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/utils/log" @@ -116,6 +118,7 @@ const ( AddConfig = "config-add" EditConfig = "config-edit" DeleteConfig = "delete-config" + ConfigShow = "config-show" // Project commands keys InitProject = "project-init" @@ -131,6 +134,10 @@ const ( ExchangeOidcToken = "exchange-oidc-token" Api = "api" + // Plugin commands keys + PluginInstall = "plugin-install" + PluginPublish = "plugin-publish" + // *** Artifactory Commands' flags *** // Base flags url = "url" @@ -494,12 +501,30 @@ const ( Stream = "stream" Periodic = "periodic" + // General output format flag + Format = "format" + + // Per-command format flag map keys. All share Name: "format" but restrict + // the description to the formats that command actually supports. + configShowFormat = "config-show-format" + accessTokenCreateFormat = "access-token-create-format" + exchangeOidcTokenFormat = "exchange-oidc-token-format" + licenseAcquireFormat = "license-acquire-format" + licenseDeployFormat = "license-deploy-format" + jpdAddFormat = "jpd-add-format" + pluginInstallFormat = "plugin-install-format" + pluginPublishFormat = "plugin-publish-format" + plStatusFormat = "pl-status-format" + plTriggerFormat = "pl-trigger-format" + plSyncFormat = "pl-sync-format" + plSyncStatusFormat = "pl-sync-status-format" + // Unique scan flags scanPrefix = "scan-" scanRecursive = scanPrefix + recursive scanRegexp = scanPrefix + regexpFlag scanAnt = scanPrefix + antFlag - xrOutput = "format" + XrFormat = "xr-format" BypassArchiveLimits = "bypass-archive-limits" // Audit commands @@ -578,7 +603,6 @@ const ( branch = "branch" Trigger = "trigger" pipelineName = "pipeline-name" - name = "name" Validate = "validate" Resources = "resources" monitor = "monitor" @@ -618,6 +642,54 @@ const ( ) var flagsMap = map[string]cli.Flag{ + configShowFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", + }, + accessTokenCreateFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, + exchangeOidcTokenFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, + licenseAcquireFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", + }, + licenseDeployFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, + jpdAddFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json, format.Table}) + "` `", + }, + pluginInstallFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json}) + "` `", + }, + pluginPublishFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json}) + "` `", + }, + plStatusFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", + }, + plTriggerFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json}) + "` `", + }, + plSyncFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Json}) + "` `", + }, + plSyncStatusFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", + }, // Common commands flags platformUrl: cli.StringFlag{ Name: url, @@ -1494,8 +1566,8 @@ var flagsMap = map[string]cli.Flag{ Name: antFlag, Usage: "[Default: false] Set to true to use an ant pattern instead of wildcards expression to collect files to scan.` `", }, - xrOutput: cli.StringFlag{ - Name: xrOutput, + XrFormat: cli.StringFlag{ + Name: Format, Usage: "[Default: table] Defines the output format of the command. Acceptable values are: table, json, simple-json and sarif. Note: the json format doesn't include information about scans that are included as part of the Advanced Security package.` `", }, BypassArchiveLimits: cli.BoolFlag{ @@ -1552,7 +1624,7 @@ var flagsMap = map[string]cli.Flag{ Usage: "[Default: 10] Number of working threads.` `", }, curationOutput: cli.StringFlag{ - Name: xrOutput, + Name: Format, Usage: "[Default: table] Defines the output format of the command. Acceptable values are: table, json.` `", }, @@ -1831,6 +1903,9 @@ var commandFlags = map[string][]string{ DeleteConfig: { deleteQuiet, }, + ConfigShow: { + configShowFormat, + }, Upload: { url, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, uploadTargetProps, ClientCertKeyPath, specFlag, specVars, BuildName, BuildNumber, module, uploadExclusions, deb, @@ -1930,14 +2005,14 @@ var commandFlags = map[string][]string{ deployIvyDesc, ivyDescPattern, ivyArtifactsPattern, }, Mvn: { - BuildName, BuildNumber, deploymentThreads, InsecureTls, Project, detailedSummary, xrayScan, xrOutput, + BuildName, BuildNumber, deploymentThreads, InsecureTls, Project, detailedSummary, xrayScan, XrFormat, }, Gradle: { - BuildName, BuildNumber, deploymentThreads, Project, serverId, detailedSummary, xrayScan, xrOutput, + BuildName, BuildNumber, deploymentThreads, Project, serverId, detailedSummary, xrayScan, XrFormat, }, Docker: { BuildName, BuildNumber, module, Project, - serverId, skipLogin, threads, detailedSummary, watches, repoPath, licenses, xrOutput, fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, vuln, validateSha, + serverId, skipLogin, threads, detailedSummary, watches, repoPath, licenses, XrFormat, fail, ExtendedTable, BypassArchiveLimits, MinSeverity, FixableOnly, vuln, validateSha, }, DockerLogin: { serverId, dockerLoginUsername, password, @@ -1972,7 +2047,7 @@ var commandFlags = map[string][]string{ BuildName, BuildNumber, module, Project, runNative, }, NpmPublish: { - BuildName, BuildNumber, module, Project, npmDetailedSummary, xrayScan, xrOutput, runNative, npmWorkspaces, + BuildName, BuildNumber, module, Project, npmDetailedSummary, xrayScan, XrFormat, runNative, npmWorkspaces, }, PnpmConfig: { global, serverIdResolve, repoResolve, @@ -2073,7 +2148,7 @@ var commandFlags = map[string][]string{ BuildName, BuildNumber, module, Project, }, Stats: { - xrOutput, accessToken, serverId, + XrFormat, accessToken, serverId, }, Api: { platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, @@ -2103,10 +2178,10 @@ var commandFlags = map[string][]string{ AccessTokenCreate: { platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, ClientCertKeyPath, atcProject, atcGrantAdmin, atcGroups, atcScope, atcExpiry, - atcRefreshable, atcDescription, atcAudience, atcReference, + atcRefreshable, atcDescription, atcAudience, atcReference, accessTokenCreateFormat, }, ExchangeOidcToken: { - url, OidcTokenID, OidcAudience, OidcProviderName, OidcProviderType, ApplicationKey, Project, repository, + url, OidcTokenID, OidcAudience, OidcProviderName, OidcProviderType, ApplicationKey, Project, repository, exchangeOidcTokenFormat, }, UserCreate: { url, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, @@ -2136,21 +2211,28 @@ var commandFlags = map[string][]string{ TransferInstall: { installPluginVersion, InstallPluginSrcDir, InstallPluginHomeDir, }, + // Plugin commands + PluginInstall: { + pluginInstallFormat, + }, + PluginPublish: { + pluginPublishFormat, + }, // Mission Control's commands McConfig: { mcUrl, mcAccessToken, mcInteractive, }, LicenseAcquire: { - mcUrl, mcAccessToken, + mcUrl, mcAccessToken, licenseAcquireFormat, }, LicenseDeploy: { - mcUrl, mcAccessToken, licenseCount, + mcUrl, mcAccessToken, licenseCount, licenseDeployFormat, }, LicenseRelease: { mcUrl, mcAccessToken, }, JpdAdd: { - mcUrl, mcAccessToken, + mcUrl, mcAccessToken, jpdAddFormat, }, JpdDelete: { mcUrl, mcAccessToken, @@ -2167,10 +2249,10 @@ var commandFlags = map[string][]string{ Intro: {}, // Pipelines commands Status: { - branch, serverId, pipelineName, monitor, singleBranch, + branch, serverId, pipelineName, monitor, singleBranch, plStatusFormat, }, Trigger: { - serverId, singleBranch, + serverId, singleBranch, plTriggerFormat, }, Validate: { Resources, serverId, @@ -2179,10 +2261,10 @@ var commandFlags = map[string][]string{ serverId, }, Sync: { - serverId, + serverId, plSyncFormat, }, SyncStatus: { - branch, repository, serverId, + branch, repository, serverId, plSyncStatusFormat, }, Setup: { serverId, url, user, password, accessToken, sshPassphrase, sshKeyPath, ClientCertPath, ClientCertKeyPath, Project, setupRepo, diff --git a/utils/cliutils/httpformat.go b/utils/cliutils/httpformat.go new file mode 100644 index 000000000..c21eca6cb --- /dev/null +++ b/utils/cliutils/httpformat.go @@ -0,0 +1,37 @@ +package cliutils + +import ( + "encoding/json" + "net/http" + "strings" + + clientUtils "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// FormatHTTPResponseJSON formats an HTTP response body as indented JSON output. +// If body is valid JSON it is printed as-is (pretty-printed). +// Otherwise a synthetic {"status_code": N, "message": "..."} is printed, where +// message is the response body text when non-empty, or the HTTP status phrase. +func FormatHTTPResponseJSON(body []byte, statusCode int) { + if len(body) > 0 { + var parsed interface{} + if json.Unmarshal(body, &parsed) == nil { + log.Output(clientUtils.IndentJson(body)) + return + } + } + msg := http.StatusText(statusCode) + if len(body) > 0 { + msg = strings.TrimSpace(string(body)) + } + synthetic := map[string]interface{}{ + "status_code": statusCode, + "message": msg, + } + data, err := json.Marshal(synthetic) + if err != nil { + return + } + log.Output(clientUtils.IndentJson(data)) +} diff --git a/utils/cliutils/httpformat_test.go b/utils/cliutils/httpformat_test.go new file mode 100644 index 000000000..647aa005f --- /dev/null +++ b/utils/cliutils/httpformat_test.go @@ -0,0 +1,38 @@ +package cliutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// FormatHTTPResponseJSON writes to log.Output, not to an io.Writer, so these +// tests verify the function runs without panicking and handles edge cases. + +func TestFormatHTTPResponseJSON_ValidJSON(t *testing.T) { + // Should not panic on valid JSON body + assert.NotPanics(t, func() { + FormatHTTPResponseJSON([]byte(`{"run_id":42,"pipeline":"my-pipeline"}`), 200) + }) +} + +func TestFormatHTTPResponseJSON_EmptyBody(t *testing.T) { + // nil body → synthesizes {"status_code":200,"message":"OK"} + assert.NotPanics(t, func() { + FormatHTTPResponseJSON(nil, 200) + }) +} + +func TestFormatHTTPResponseJSON_NonJSONBody(t *testing.T) { + // Plain-text body → synthesizes status object with message = body text + assert.NotPanics(t, func() { + FormatHTTPResponseJSON([]byte("Internal Server Error"), 500) + }) +} + +func TestFormatHTTPResponseJSON_InvalidJSONFallback(t *testing.T) { + // Partial/corrupt JSON → falls back to synthetic object + assert.NotPanics(t, func() { + FormatHTTPResponseJSON([]byte(`{"broken`), 400) + }) +}