Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
501a39c
Add shared resolvePositionalArg for auth commands
simonfaltum Mar 25, 2026
6f0541c
Refactor resolve tests to table-driven, tighten looksLikeHost check
simonfaltum Mar 25, 2026
a56e29e
auth login: treat positional arg as profile name first
simonfaltum Mar 25, 2026
487244e
auth token: use resolvePositionalArg for better error messages
simonfaltum Mar 25, 2026
9037a5c
auth logout: use shared resolvePositionalArg, remove local --profile
simonfaltum Mar 25, 2026
5d92ceb
Reword login error message, co-locate resolveHostToProfile tests
simonfaltum Mar 25, 2026
bd2673c
Fix whitespace and lint in logout.go and resolve.go
simonfaltum Mar 25, 2026
1bf93ff
Fix review findings for auth positional profile resolution
simonfaltum Mar 25, 2026
01e1167
Add NEXT_CHANGELOG entry for auth positional profile resolution
simonfaltum Mar 25, 2026
b6db124
Use [PROFILE] in usage strings, keep host fallback silent
simonfaltum Apr 7, 2026
dc1b3bc
Address review feedback: sentinel errors, nil guard removal, profile+…
simonfaltum Apr 7, 2026
8f180cd
Fix lint (assert.ErrorIs) and remove incorrect login profile+arg conf…
simonfaltum Apr 7, 2026
83bf8b9
Error when positional arg is combined with --host or --profile
simonfaltum Apr 8, 2026
6599d8b
Align login help text with #4906 and document profile-first resolution
simonfaltum Apr 8, 2026
f4bf117
Use [PROFILE] in login usage string
simonfaltum Apr 8, 2026
f5bc0c3
Fix host-arg-overrides-profile test to expect conflict error
simonfaltum Apr 8, 2026
9f58730
Merge branch 'main' into simonfaltum/auth-positional-profile
simonfaltum Apr 8, 2026
e9e3b38
Merge branch 'main' into simonfaltum/auth-positional-profile
simonfaltum Apr 8, 2026
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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Notable Changes

### CLI
* Auth commands now accept a profile name as a positional argument ([#4840](https://github.com/databricks/cli/pull/4840))

### Bundles

Expand Down

This file was deleted.

15 changes: 4 additions & 11 deletions acceptance/cmd/auth/login/host-arg-overrides-profile/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,8 @@
[override-test]
host = https://old-host.cloud.databricks.com

=== Login with positional host argument (should override profile)
>>> [CLI] auth login [DATABRICKS_URL] --profile override-test
Profile override-test was successfully saved
=== Login with --host flag (should error on conflict)
>>> [CLI] auth login --host [DATABRICKS_URL] --profile override-test
Error: --profile "override-test" has host "https://old-host.cloud.databricks.com", which conflicts with --host "[DATABRICKS_URL]". Use --profile only to select a profile

=== Profile after login (host should be updated)
; The profile defined in the DEFAULT section is to be used as a fallback when no profile is explicitly specified.
[DEFAULT]

[override-test]
host = [DATABRICKS_URL]
workspace_id = [NUMID]
auth_type = databricks-cli
Exit code: 1
18 changes: 4 additions & 14 deletions acceptance/cmd/auth/login/host-arg-overrides-profile/script
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,7 @@ EOF
title "Initial profile with old host\n"
cat "./home/.databrickscfg"

# Use a fake browser that performs a GET on the authorization URL
# and follows the redirect back to localhost.
export BROWSER="browser.py"

# Login with profile but provide a different host as positional argument
# The positional argument should override the profile's host
title "Login with positional host argument (should override profile)"
trace $CLI auth login $DATABRICKS_HOST --profile override-test

title "Profile after login (host should be updated)\n"
cat "./home/.databrickscfg"

# Track the .databrickscfg file that was created to surface changes.
mv "./home/.databrickscfg" "./out.databrickscfg"
# Login with --profile and --host where the profile has a different host.
# This should error because the profile's host conflicts with --host.
title "Login with --host flag (should error on conflict)"
trace $CLI auth login --host $DATABRICKS_HOST --profile override-test
31 changes: 27 additions & 4 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command {
defaultConfigPath = "%USERPROFILE%\\.databrickscfg"
}
cmd := &cobra.Command{
Use: "login [HOST]",
Use: "login [PROFILE]",
Short: "Log into a Databricks workspace or account",
Long: fmt.Sprintf(`Log into a Databricks workspace or account.

Expand All @@ -109,9 +109,12 @@ information, see:
If no host is provided, the CLI opens login.databricks.com where you can
authenticate and select a workspace.

The host can be provided as a positional argument, via --host, or from an
existing profile. The host URL may include query parameters to set the
workspace and account ID:
The positional argument is resolved as a profile name first. If no profile
with that name exists and the argument looks like a URL, it is used as a
host. The positional argument cannot be combined with --host or --profile;
use the flags directly to specify both.

The host URL may include query parameters to set the workspace and account ID:

databricks auth login --host "https://<host>?o=<workspace_id>&account_id=<id>"

Expand Down Expand Up @@ -149,6 +152,26 @@ a new profile is created.
return errors.New("please either configure serverless or cluster, not both")
}

// The positional argument is a shorthand that resolves to either a
// profile or a host. It cannot be combined with explicit flags.
// Use "databricks auth login --host X --profile Y" instead.
if len(args) > 0 && (authArguments.Host != "" || profileName != "") {
return fmt.Errorf("argument %q cannot be combined with --host or --profile. Use the --host and --profile flags instead", args[0])
}
if len(args) == 1 {
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profile.DefaultProfiler)
if err != nil {
return err
}
if resolvedProfile != "" {
profileName = resolvedProfile
args = nil
} else {
authArguments.Host = resolvedHost
args = nil
}
}

// If the user has not specified a profile name, prompt for one.
if profileName == "" {
var err error
Expand Down
22 changes: 22 additions & 0 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -992,3 +992,25 @@ auth_type = databricks-cli
assert.Equal(t, "fresh-account", savedProfile.AccountID, "account_id should be saved from introspection")
assert.Equal(t, "222222", savedProfile.WorkspaceID, "workspace_id should be updated to fresh introspection value")
}

func TestLoginRejectsPositionalArgWithHostFlag(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
authArgs := &auth.AuthArguments{Host: "https://example.com"}
cmd := newLoginCommand(authArgs)
cmd.Flags().String("profile", "", "")
cmd.SetContext(ctx)
cmd.SetArgs([]string{"myprofile"})
err := cmd.Execute()
assert.ErrorContains(t, err, `argument "myprofile" cannot be combined with --host or --profile`)
}

func TestLoginRejectsPositionalArgWithProfileFlag(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
authArgs := &auth.AuthArguments{}
cmd := newLoginCommand(authArgs)
cmd.Flags().String("profile", "", "")
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--profile", "myprofile", "https://example.com"})
err := cmd.Execute()
assert.ErrorContains(t, err, `argument "https://example.com" cannot be combined with --host or --profile`)
}
77 changes: 17 additions & 60 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,35 @@ the profile is an error.
}

var force bool
var profileName string
var deleteProfile bool
cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt")
cmd.Flags().StringVar(&profileName, "profile", "", "The profile to log out of")
cmd.Flags().BoolVar(&deleteProfile, "delete", false, "Delete the profile from the config file")

cmd.RunE = func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
profiler := profile.DefaultProfiler

// Resolve the positional argument to a profile name.
if profileName != "" && len(args) == 1 {
return errors.New("providing both --profile and a positional argument is not supported")
profileFlag := cmd.Flag("profile")
profileName := profileFlag.Value.String()

// The positional argument is a shorthand that resolves to either a
// profile or a host. It cannot be combined with explicit flags.
if profileFlag.Changed && len(args) == 1 {
return fmt.Errorf("argument %q cannot be combined with --profile. Use the --profile flag instead", args[0])
}
if profileName == "" && len(args) == 1 {
resolved, err := resolveLogoutArg(ctx, args[0], profiler)
if len(args) == 1 {
resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profiler)
if err != nil {
return err
}
profileName = resolved
if resolvedProfile != "" {
profileName = resolvedProfile
} else {
profileName, err = resolveHostToProfile(ctx, resolvedHost, profiler)
if err != nil {
return err
}
}
}

if profileName == "" {
Expand Down Expand Up @@ -289,55 +298,3 @@ func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunc

return host, profile.WithHost(host)
}

// resolveLogoutArg resolves a positional argument to a profile name. It first
// tries to match the argument as a profile name, then as a host URL. If the
// host matches multiple profiles in a non-interactive context, it returns an
// error listing the matching profile names.
func resolveLogoutArg(ctx context.Context, arg string, profiler profile.Profiler) (string, error) {
// Try as profile name first.
candidateProfile, err := loadProfileByName(ctx, arg, profiler)
if err != nil {
return "", err
}
if candidateProfile != nil {
return arg, nil
}

// Try as host URL.
canonicalHost := (&config.Config{Host: arg}).CanonicalHostName()
hostProfiles, err := profiler.LoadProfiles(ctx, profile.WithHost(canonicalHost))
if err != nil {
return "", err
}

switch len(hostProfiles) {
case 1:
return hostProfiles[0].Name, nil
case 0:
allProfiles, err := profiler.LoadProfiles(ctx, profile.MatchAllProfiles)
if err != nil {
return "", fmt.Errorf("no profile found matching %q", arg)
}
names := strings.Join(allProfiles.Names(), ", ")
return "", fmt.Errorf("no profile found matching %q. Available profiles: %s", arg, names)
default:
// Multiple profiles match the host.
if cmdio.IsPromptSupported(ctx) {
selected, err := profile.SelectProfile(ctx, profile.SelectConfig{
Label: fmt.Sprintf("Multiple profiles found for %q. Select one to log out of", arg),
Profiles: hostProfiles,
StartInSearchMode: len(hostProfiles) > 5,
ActiveTemplate: `▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}`,
InactiveTemplate: ` {{.PaddedName}}{{if .AccountID}} (account: {{.AccountID | faint}}){{else}} ({{.Host | faint}}){{end}}`,
SelectedTemplate: `{{ "Selected profile" | faint }}: {{ .Name | bold }}`,
})
if err != nil {
return "", err
}
return selected, nil
}
names := strings.Join(hostProfiles.Names(), ", ")
return "", fmt.Errorf("multiple profiles found matching host %q: %s. Please specify the profile name directly", arg, names)
}
}
94 changes: 7 additions & 87 deletions cmd/auth/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/databrickscfg"
"github.com/databricks/cli/libs/databrickscfg/profile"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -262,95 +263,14 @@ func TestLogoutNoTokensWithDelete(t *testing.T) {
assert.Empty(t, profiles)
}

func TestLogoutResolveArgMatchesProfileName(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

resolved, err := resolveLogoutArg(ctx, "dev", profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
}

func TestLogoutResolveArgMatchesHostWithOneProfile(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

resolved, err := resolveLogoutArg(ctx, "https://dev.cloud.databricks.com", profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
}

func TestLogoutResolveArgMatchesHostWithMultipleProfiles(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev1", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "dev2", Host: "https://shared.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

_, err := resolveLogoutArg(ctx, "https://shared.cloud.databricks.com", profiler)
assert.ErrorContains(t, err, "multiple profiles found matching host")
assert.ErrorContains(t, err, "dev1")
assert.ErrorContains(t, err, "dev2")
}

func TestLogoutResolveArgMatchesNothing(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
{Name: "staging", Host: "https://staging.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

_, err := resolveLogoutArg(ctx, "https://unknown.cloud.databricks.com", profiler)
assert.ErrorContains(t, err, `no profile found matching "https://unknown.cloud.databricks.com"`)
assert.ErrorContains(t, err, "dev")
assert.ErrorContains(t, err, "staging")
}

func TestLogoutResolveArgCanonicalizesHost(t *testing.T) {
profiler := profile.InMemoryProfiler{
Profiles: profile.Profiles{
{Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"},
},
}

cases := []struct {
name string
arg string
}{
{name: "canonical URL", arg: "https://dev.cloud.databricks.com"},
{name: "trailing slash", arg: "https://dev.cloud.databricks.com/"},
{name: "no scheme", arg: "dev.cloud.databricks.com"},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := cmdio.MockDiscard(t.Context())
resolved, err := resolveLogoutArg(ctx, tc.arg, profiler)
require.NoError(t, err)
assert.Equal(t, "dev", resolved)
})
}
}

func TestLogoutProfileFlagAndPositionalArgConflict(t *testing.T) {
parent := &cobra.Command{Use: "root"}
parent.PersistentFlags().StringP("profile", "p", "", "~/.databrickscfg profile")
cmd := newLogoutCommand()
cmd.SetArgs([]string{"myprofile", "--profile", "other"})
err := cmd.Execute()
assert.ErrorContains(t, err, "providing both --profile and a positional argument is not supported")
parent.AddCommand(cmd)
parent.SetArgs([]string{"logout", "myprofile", "--profile", "other"})
err := parent.Execute()
assert.ErrorContains(t, err, `argument "myprofile" cannot be combined with --profile`)
}

func TestLogoutDeleteClearsDefaultProfile(t *testing.T) {
Expand Down
Loading
Loading