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
9 changes: 4 additions & 5 deletions cmd/auth/login/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,9 @@ func RunE(cmd *cobra.Command, args []string) error { //nolint: cyclop
// when authentication is intentionally disabled, say if the user is offline, or in
// a CI/CD environment, or in a script.
if viperx.GetBool(config.SkipAuthKey) {
err := errors.New("Login has been disabled via the '--skip-auth' flag.")
log.Error(err)
log.Error(errors.New("Login has been disabled via the '--skip-auth' flag."))

return err
return cli.ErrSilent
}

var url string
Expand Down Expand Up @@ -86,7 +85,7 @@ func RunE(cmd *cobra.Command, args []string) error { //nolint: cyclop

cmd.SilenceUsage = true

return err
return cli.ErrSilent
}

if key == "" {
Expand All @@ -101,7 +100,7 @@ func RunE(cmd *cobra.Command, args []string) error { //nolint: cyclop

cmd.SilenceUsage = true

return err
return cli.ErrSilent
}

return nil
Expand Down
48 changes: 48 additions & 0 deletions cmd/auth/login/cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package login

import (
"testing"

"github.com/datarobot/cli/internal/cli"
"github.com/datarobot/cli/internal/config"
"github.com/datarobot/cli/internal/config/viperx"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestRunE_SkipAuthReturnsErrSilent covers the --skip-auth short circuit, the one
// login failure path that needs no network or config access. RunE logs the reason
// itself, so it must return cli.ErrSilent; a plain error would have main.go print
// the same thing again (CFX-6924).
func TestRunE_SkipAuthReturnsErrSilent(t *testing.T) {
viperx.Set(config.SkipAuthKey, true)
t.Cleanup(func() { viperx.Set(config.SkipAuthKey, false) })

err := RunE(&cobra.Command{}, nil)

require.Error(t, err)
assert.ErrorIs(t, err, cli.ErrSilent,
"the reason is already logged, so the error must not be printed again")
}

// TestCmd_SilenceErrors documents that the command still opts out of cobra's
// printing. Reporting is centralized in main.go via cmd.ReportError, which honours
// cli.ErrSilent; this flag keeps cobra from adding a line of its own.
func TestCmd_SilenceErrors(t *testing.T) {
assert.True(t, Cmd().SilenceErrors)
}
21 changes: 21 additions & 0 deletions cmd/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,31 @@
package cmd

import (
"errors"
"fmt"
"io"
"os"
"time"

"github.com/datarobot/cli/internal/cli"
)

// ReportError writes a user-facing "Error: ..." line for err to w, unless err is
// cli.ErrSilent — the sentinel meaning a command already printed its own message.
//
// RootCmd sets SilenceErrors so cobra never prints errors itself, which makes this
// the single place errors reach the user. Previously cobra was the only reporter,
// and every command setting SilenceErrors: true silently swallowed failures raised
// by the root PersistentPreRunE (config loading, TLS setup) that it never had a
// chance to print — a bad --ca-cert path exited 1 with no output at all (CFX-6924).
func ReportError(w io.Writer, err error) {
if err == nil || errors.Is(err, cli.ErrSilent) {
return
}

fmt.Fprintln(w, "Error:", err)
}

// Exit flushes any pending telemetry events then terminates the process with
// code. Call this from main (instead of os.Exit) when ExecuteContext returns
// an error, so that Amplitude events are delivered even though
Expand Down
84 changes: 84 additions & 0 deletions cmd/exit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"bytes"
"errors"
"fmt"
"testing"

"github.com/datarobot/cli/internal/cli"
"github.com/stretchr/testify/assert"
)

func TestReportError(t *testing.T) {
t.Parallel()

tests := []struct {
name string
err error
want string
}{
{
name: "nil error prints nothing",
err: nil,
want: "",
},
{
name: "ErrSilent prints nothing because the command already reported it",
err: cli.ErrSilent,
want: "",
},
{
name: "an error wrapping ErrSilent is still silent",
err: fmt.Errorf("auth check: %w", cli.ErrSilent),
want: "",
},
{
name: "an ordinary error is reported",
err: errors.New("boom"),
want: "Error: boom\n",
},
{
name: "a TLS setup failure from the root PersistentPreRunE is reported",
err: errors.New(`apply tls options: reading CA cert "C:\nope.pem": no such file or directory`),
want: "Error: apply tls options: reading CA cert \"C:\\nope.pem\": no such file or directory\n",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

ReportError(&buf, tt.err)

assert.Equal(t, tt.want, buf.String())
})
}
}

// TestRootSilencesErrorsForSingleReporting locks in the contract that makes
// ReportError the only error reporter. If cobra were allowed to print too, errors
// raised by the root PersistentPreRunE would either double-print or, for commands
// setting SilenceErrors: true, disappear entirely (CFX-6924).
func TestRootSilencesErrorsForSingleReporting(t *testing.T) {
t.Parallel()

assert.True(t, RootCmd.SilenceErrors,
"RootCmd.SilenceErrors must stay true so cmd.ReportError is the single error reporter")
}
18 changes: 13 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,13 @@ using pre-built templates. Get from idea to production in minutes, not hours.
// ExecuteContext executes the root command with the given context.
// It adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
// The error is returned unwrapped on purpose. main.go reports it verbatim through
// cmd.ReportError, so an "execute root command: ..." wrapper would surface in
// user-facing output and add nothing — this is the process boundary, and there is no
// sibling call site an extra frame could disambiguate.
func ExecuteContext(ctx context.Context) error {
if err := RootCmd.ExecuteContext(ctx); err != nil {
return fmt.Errorf("execute root command: %w", err)
}

return nil
//nolint:wrapcheck // top-level passthrough; wrapping would leak into user output
return RootCmd.ExecuteContext(ctx)
}

// bindUniversal binds name to viper and annotates the flag for forwarding to
Expand All @@ -224,6 +225,13 @@ func init() {
// Disable Cobra's default completion command since we have our own under 'self'
RootCmd.CompletionOptions.DisableDefaultCmd = true

// Silence cobra's own error printing so main.go, via cmd.ReportError, is the single
// reporter. Cobra skips printing whenever the executed command sets
// SilenceErrors: true, which used to mean errors raised by the root
// PersistentPreRunE (config, TLS) vanished entirely for those commands. Centralizing
// it also removes the risk of the same error being printed twice. See CFX-6924.
RootCmd.SilenceErrors = true

// Set custom version template to match our unified format
RootCmd.SetVersionTemplate(internalVersion.GetAppNameVersionText() + "\n\nTo update: dr self update\n")

Expand Down
41 changes: 41 additions & 0 deletions cmd/tls_help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"
"io"
)

// fprintNoWindowsCertsHelp explains what to do when --export-windows-certs finds an
// empty certificate store. The export itself worked; there is simply nothing to
// export, which is a machine-configuration problem the user can resolve.
//
// Kept build-tag free so it can be unit tested on any host, although only the
// Windows build calls it.
func fprintNoWindowsCertsHelp(w io.Writer) {
fmt.Fprintln(w, "The Windows certificate store contains no Root or CA certificates to export.")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " Check what is installed:")
fmt.Fprintln(w, " Get-ChildItem Cert:\\LocalMachine\\Root")
fmt.Fprintln(w, " Get-ChildItem Cert:\\CurrentUser\\Root")
fmt.Fprintln(w, " or open certlm.msc (machine) / certmgr.msc (user).")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " If your organization's root CA is missing, import it and retry:")
fmt.Fprintln(w, " Import-Certificate -FilePath <ca.crt> -CertStoreLocation Cert:\\CurrentUser\\Root")
fmt.Fprintln(w, "")
fmt.Fprintln(w, " To trust a PEM bundle directly instead of the Windows store:")
fmt.Fprintln(w, " dr --ca-cert <path-to-ca.pem> <command>")
}
49 changes: 49 additions & 0 deletions cmd/tls_help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"bytes"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

// TestFprintNoWindowsCertsHelp checks the guidance is actionable rather than merely
// restating the failure: it must name how to inspect the store, how to import a CA,
// and the --ca-cert escape hatch.
func TestFprintNoWindowsCertsHelp(t *testing.T) {
t.Parallel()

var buf bytes.Buffer

fprintNoWindowsCertsHelp(&buf)

out := buf.String()

for _, want := range []string{
"no Root or CA certificates",
`Cert:\LocalMachine\Root`,
`Cert:\CurrentUser\Root`,
"certlm.msc",
"Import-Certificate",
"dr --ca-cert",
} {
assert.Contains(t, out, want)
}

assert.True(t, strings.HasSuffix(out, "\n"), "guidance must end with a newline")
}
14 changes: 14 additions & 0 deletions cmd/tls_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/datarobot/cli/internal/cli"
"github.com/datarobot/cli/internal/config"
internaltls "github.com/datarobot/cli/internal/tls"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -54,6 +57,17 @@ func applyWindowsCerts(cmd *cobra.Command, caCert *string) error {
}

if err := internaltls.ExportWindowsCerts(dest); err != nil {
// An empty store is fixable by the user, so report it and say how. Both go to
// stderr, keeping stdout parseable under --output-format json. Reporting here
// rather than letting main.go do it keeps the guidance below its own error
// message instead of above it; ErrSilent then suppresses the second print.
if errors.Is(err, internaltls.ErrNoWindowsCerts) {
ReportError(os.Stderr, fmt.Errorf("--export-windows-certs: %w", err))
fprintNoWindowsCertsHelp(os.Stderr)

return cli.ErrSilent
}

return fmt.Errorf("--export-windows-certs: %w", err)
}

Expand Down
12 changes: 9 additions & 3 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"testing"

"github.com/charmbracelet/lipgloss"
"github.com/datarobot/cli/internal/cli"
"github.com/datarobot/cli/internal/config"
"github.com/datarobot/cli/internal/config/viperx"
"github.com/datarobot/cli/internal/log"
Expand Down Expand Up @@ -309,11 +310,16 @@ func VerifyEnvCredentials(ctx context.Context) (*EnvCredentials, error) {

// EnsureAuthenticatedE checks if valid authentication exists, and if not,
// triggers the login flow automatically (see EnsureAuthenticated for the
// exceptions). Returns an error if authentication fails, suitable for use in
// Cobra PreRunE hooks.
// exceptions). Suitable for use in Cobra PreRunE hooks.
//
// On failure it returns cli.ErrSilent, not a descriptive error: EnsureAuthenticated
// has already told the user what went wrong, and main.go prints anything else.
func EnsureAuthenticatedE(cmd *cobra.Command, _ []string) error {
if !EnsureAuthenticated(cmd.Context()) {
return errors.New("authentication failed")
// Every path where EnsureAuthenticated reports false has already written a
// user-facing message, so signal that rather than returning a fresh error
// main.go would print a second time.
return cli.ErrSilent
}

return nil
Expand Down
Loading
Loading