From 37bf6b0378b2d41796ea4f498935053f9c0465ee Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:32:53 +0100 Subject: [PATCH 1/9] Add shared resolvePositionalArg for auth commands --- cmd/auth/resolve.go | 37 ++++++++++++++++ cmd/auth/resolve_test.go | 96 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 cmd/auth/resolve.go create mode 100644 cmd/auth/resolve_test.go diff --git a/cmd/auth/resolve.go b/cmd/auth/resolve.go new file mode 100644 index 0000000000..4fb35ce80f --- /dev/null +++ b/cmd/auth/resolve.go @@ -0,0 +1,37 @@ +package auth + +import ( + "context" + "fmt" + "strings" + + "github.com/databricks/cli/libs/databrickscfg/profile" +) + +// looksLikeHost returns true if the argument looks like a host URL rather than +// a profile name. Profile names are short identifiers (e.g., "logfood", +// "DEFAULT"), while host URLs contain dots or start with "http". +func looksLikeHost(arg string) bool { + return strings.Contains(arg, ".") || strings.HasPrefix(arg, "http") +} + +// resolvePositionalArg resolves a positional argument to either a profile name +// or a host. It tries the argument as a profile name first. If no profile +// matches and the argument looks like a host URL, it returns it as a host. If +// no profile matches and the argument does not look like a host, it returns an +// error. +func resolvePositionalArg(ctx context.Context, arg string, profiler profile.Profiler) (profileName string, host string, err error) { + candidateProfile, err := loadProfileByName(ctx, arg, profiler) + if err != nil { + return "", "", err + } + if candidateProfile != nil { + return arg, "", nil + } + + if looksLikeHost(arg) { + return "", arg, nil + } + + return "", "", fmt.Errorf("no profile named %q found", arg) +} diff --git a/cmd/auth/resolve_test.go b/cmd/auth/resolve_test.go new file mode 100644 index 0000000000..b6135d4689 --- /dev/null +++ b/cmd/auth/resolve_test.go @@ -0,0 +1,96 @@ +package auth + +import ( + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolvePositionalArgMatchesProfile(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + } + + profileName, host, err := resolvePositionalArg(ctx, "logfood", profiler) + require.NoError(t, err) + assert.Equal(t, "logfood", profileName) + assert.Empty(t, host) +} + +func TestResolvePositionalArgFallsBackToHost(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + } + + profileName, host, err := resolvePositionalArg(ctx, "https://other.cloud.databricks.com", profiler) + require.NoError(t, err) + assert.Empty(t, profileName) + assert.Equal(t, "https://other.cloud.databricks.com", host) +} + +func TestResolvePositionalArgFallsBackToHostWithDot(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{}, + } + + profileName, host, err := resolvePositionalArg(ctx, "my-workspace.cloud.databricks.com", profiler) + require.NoError(t, err) + assert.Empty(t, profileName) + assert.Equal(t, "my-workspace.cloud.databricks.com", host) +} + +func TestResolvePositionalArgErrorsForNonHostNonProfile(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + } + + _, _, err := resolvePositionalArg(ctx, "e2-logfood", profiler) + assert.ErrorContains(t, err, `no profile named "e2-logfood" found`) +} + +func TestResolvePositionalArgHttpPrefix(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{}, + } + + profileName, host, err := resolvePositionalArg(ctx, "http://localhost:8080", profiler) + require.NoError(t, err) + assert.Empty(t, profileName) + assert.Equal(t, "http://localhost:8080", host) +} + +func TestResolvePositionalArgEmptyProfiles(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{} + + _, _, err := resolvePositionalArg(ctx, "myprofile", profiler) + assert.ErrorContains(t, err, `no profile named "myprofile" found`) +} + +func TestResolvePositionalArgProfileWithDotInName(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{ + Profiles: profile.Profiles{ + {Name: "default.dev", Host: "https://dev.cloud.databricks.com"}, + }, + } + + profileName, host, err := resolvePositionalArg(ctx, "default.dev", profiler) + require.NoError(t, err) + assert.Equal(t, "default.dev", profileName) + assert.Empty(t, host) +} From 179429ae6e3a8b4578c66d8103e55b2a9dd6e373 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:44:17 +0100 Subject: [PATCH 2/9] Refactor resolve tests to table-driven, tighten looksLikeHost check Co-authored-by: Isaac --- cmd/auth/resolve.go | 2 +- cmd/auth/resolve_test.go | 152 +++++++++++++++++++-------------------- 2 files changed, 75 insertions(+), 79 deletions(-) diff --git a/cmd/auth/resolve.go b/cmd/auth/resolve.go index 4fb35ce80f..cf3a97646b 100644 --- a/cmd/auth/resolve.go +++ b/cmd/auth/resolve.go @@ -12,7 +12,7 @@ import ( // a profile name. Profile names are short identifiers (e.g., "logfood", // "DEFAULT"), while host URLs contain dots or start with "http". func looksLikeHost(arg string) bool { - return strings.Contains(arg, ".") || strings.HasPrefix(arg, "http") + return strings.Contains(arg, ".") || strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") } // resolvePositionalArg resolves a positional argument to either a profile name diff --git a/cmd/auth/resolve_test.go b/cmd/auth/resolve_test.go index b6135d4689..7a4a4e5441 100644 --- a/cmd/auth/resolve_test.go +++ b/cmd/auth/resolve_test.go @@ -9,88 +9,84 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolvePositionalArgMatchesProfile(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{ - {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, +func TestResolvePositionalArg(t *testing.T) { + cases := []struct { + name string + profiles profile.Profiles + arg string + wantProfile string + wantHost string + wantErr string + }{ + { + name: "matches profile", + profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + arg: "logfood", + wantProfile: "logfood", + wantHost: "", }, - } - - profileName, host, err := resolvePositionalArg(ctx, "logfood", profiler) - require.NoError(t, err) - assert.Equal(t, "logfood", profileName) - assert.Empty(t, host) -} - -func TestResolvePositionalArgFallsBackToHost(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{ - {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + { + name: "falls back to https host", + profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + arg: "https://other.cloud.databricks.com", + wantProfile: "", + wantHost: "https://other.cloud.databricks.com", }, - } - - profileName, host, err := resolvePositionalArg(ctx, "https://other.cloud.databricks.com", profiler) - require.NoError(t, err) - assert.Empty(t, profileName) - assert.Equal(t, "https://other.cloud.databricks.com", host) -} - -func TestResolvePositionalArgFallsBackToHostWithDot(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{}, - } - - profileName, host, err := resolvePositionalArg(ctx, "my-workspace.cloud.databricks.com", profiler) - require.NoError(t, err) - assert.Empty(t, profileName) - assert.Equal(t, "my-workspace.cloud.databricks.com", host) -} - -func TestResolvePositionalArgErrorsForNonHostNonProfile(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{ - {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + { + name: "falls back to host with dot", + profiles: profile.Profiles{}, + arg: "my-workspace.cloud.databricks.com", + wantProfile: "", + wantHost: "my-workspace.cloud.databricks.com", }, - } - - _, _, err := resolvePositionalArg(ctx, "e2-logfood", profiler) - assert.ErrorContains(t, err, `no profile named "e2-logfood" found`) -} - -func TestResolvePositionalArgHttpPrefix(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{}, - } - - profileName, host, err := resolvePositionalArg(ctx, "http://localhost:8080", profiler) - require.NoError(t, err) - assert.Empty(t, profileName) - assert.Equal(t, "http://localhost:8080", host) -} - -func TestResolvePositionalArgEmptyProfiles(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{} - - _, _, err := resolvePositionalArg(ctx, "myprofile", profiler) - assert.ErrorContains(t, err, `no profile named "myprofile" found`) -} - -func TestResolvePositionalArgProfileWithDotInName(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) - profiler := profile.InMemoryProfiler{ - Profiles: profile.Profiles{ - {Name: "default.dev", Host: "https://dev.cloud.databricks.com"}, + { + name: "errors for non-host non-profile", + profiles: profile.Profiles{ + {Name: "logfood", Host: "https://logfood.cloud.databricks.com"}, + }, + arg: "e2-logfood", + wantErr: `no profile named "e2-logfood" found`, + }, + { + name: "http prefix", + profiles: profile.Profiles{}, + arg: "http://localhost:8080", + wantProfile: "", + wantHost: "http://localhost:8080", + }, + { + name: "empty profiles error", + profiles: profile.Profiles{}, + arg: "myprofile", + wantErr: `no profile named "myprofile" found`, + }, + { + name: "profile with dot in name", + profiles: profile.Profiles{ + {Name: "default.dev", Host: "https://dev.cloud.databricks.com"}, + }, + arg: "default.dev", + wantProfile: "default.dev", + wantHost: "", }, } - profileName, host, err := resolvePositionalArg(ctx, "default.dev", profiler) - require.NoError(t, err) - assert.Equal(t, "default.dev", profileName) - assert.Empty(t, host) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := cmdio.MockDiscard(t.Context()) + profiler := profile.InMemoryProfiler{Profiles: tc.profiles} + profileName, host, err := resolvePositionalArg(ctx, tc.arg, profiler) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantProfile, profileName) + assert.Equal(t, tc.wantHost, host) + }) + } } From 2fb8c942da37aaf74659e67b94a5485e1f22d923 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:47:20 +0100 Subject: [PATCH 3/9] auth login: treat positional arg as profile name first Co-authored-by: Isaac --- cmd/auth/login.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 379ce1a4c2..2d7657d694 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -93,7 +93,7 @@ func newLoginCommand(authArguments *auth.AuthArguments) *cobra.Command { defaultConfigPath = "%USERPROFILE%\\.databrickscfg" } cmd := &cobra.Command{ - Use: "login [HOST]", + Use: "login [PROFILE_OR_HOST]", Short: "Log into a Databricks workspace or account", Long: fmt.Sprintf(`Log into a Databricks workspace or account. This command logs you into the Databricks workspace or account and saves @@ -109,7 +109,9 @@ you can refer to the documentation linked below. GCP: https://docs.gcp.databricks.com/dev-tools/auth/index.html -If no host is provided (via --host, as a positional argument, or from an existing +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. If no +host is provided (via --host, as a positional argument, or from an existing profile), the CLI will open login.databricks.com where you can authenticate and select a workspace. The workspace URL will be discovered automatically. @@ -161,6 +163,24 @@ depends on the existing profiles you have set in your configuration file return errors.New("please either configure serverless or cluster, not both") } + // Resolve positional argument as profile or host. + if len(args) > 0 && authArguments.Host != "" { + return errors.New("please only provide a host as an argument or a flag, not both") + } + if profileName == "" && 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 From 796f42eb0709a1248e0e13ddaaf136541e3c60b9 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:48:06 +0100 Subject: [PATCH 4/9] auth token: use resolvePositionalArg for better error messages Co-authored-by: Isaac --- cmd/auth/token.go | 13 ++++++++----- cmd/auth/token_test.go | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 79f99726be..3008e0172b 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -130,15 +130,18 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { args.profileName = env.Get(ctx, "DATABRICKS_CONFIG_PROFILE") } - // If no --profile flag, try resolving the positional arg as a profile name. - // If it matches, use it. If not, fall through to host treatment. + // If no --profile flag, resolve the positional arg as a profile name first, + // then as a host. Error if it matches neither. if args.profileName == "" && len(args.args) == 1 { - candidateProfile, err := loadProfileByName(ctx, args.args[0], args.profiler) + resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args.args[0], args.profiler) if err != nil { return nil, err } - if candidateProfile != nil { - args.profileName = args.args[0] + if resolvedProfile != "" { + args.profileName = resolvedProfile + args.args = nil + } else { + args.authArguments.Host = resolvedHost args.args = nil } } diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index aa343eb372..edcfb17f67 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -367,7 +367,7 @@ func TestToken_loadToken(t *testing.T) { args: loadTokenArgs{ authArguments: &auth.AuthArguments{}, profileName: "", - args: []string{"nonexistent"}, + args: []string{"nonexistent.cloud.databricks.com"}, tokenTimeout: 1 * time.Hour, profiler: profiler, persistentAuthOpts: []u2m.PersistentAuthOption{ @@ -376,9 +376,20 @@ func TestToken_loadToken(t *testing.T) { }, }, wantErr: "cache: databricks OAuth is not configured for this host. " + - "Try logging in again with `databricks auth login --host https://nonexistent` before retrying. " + + "Try logging in again with `databricks auth login --host https://nonexistent.cloud.databricks.com` before retrying. " + "If this fails, please report this issue to the Databricks CLI maintainers at https://github.com/databricks/cli/issues/new", }, + { + name: "errors with clear message for non-host non-profile positional arg", + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{"e2-logfood"}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + }, + wantErr: `no profile named "e2-logfood" found`, + }, { name: "scheme-less account host ambiguity detected correctly", args: loadTokenArgs{ From c4eae903ea50e15f4f7b3db8fa222b935c2a0532 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:50:40 +0100 Subject: [PATCH 5/9] auth logout: use shared resolvePositionalArg, remove local --profile Co-authored-by: Isaac --- cmd/auth/logout.go | 74 ++++++++++------------------------------- cmd/auth/logout_test.go | 40 +++++++++------------- cmd/auth/resolve.go | 42 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 82 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 864ac34033..254e451e6f 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -28,7 +28,7 @@ You will need to run {{ "databricks auth login" | bold }} to re-authenticate. func newLogoutCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "logout [PROFILE]", + Use: "logout [PROFILE_OR_HOST]", Short: "Log out of a Databricks profile", Args: cobra.MaximumNArgs(1), Hidden: true, @@ -66,26 +66,37 @@ 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 + profileName := "" + profileFlag := cmd.Flag("profile") + if profileFlag != nil { + profileName = profileFlag.Value.String() + } + // Resolve the positional argument to a profile name. - if profileName != "" && len(args) == 1 { + if profileFlag != nil && profileFlag.Changed && len(args) == 1 { return errors.New("providing both --profile and a positional argument is not supported") } if profileName == "" && len(args) == 1 { - resolved, err := resolveLogoutArg(ctx, args[0], profiler) + 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 == "" { @@ -290,54 +301,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) - } -} diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 7468a0779b..467a2e709c 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -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" @@ -262,7 +263,7 @@ func TestLogoutNoTokensWithDelete(t *testing.T) { assert.Empty(t, profiles) } -func TestLogoutResolveArgMatchesProfileName(t *testing.T) { +func TestResolveHostToProfileMatchesOneProfile(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) profiler := profile.InMemoryProfiler{ Profiles: profile.Profiles{ @@ -271,26 +272,12 @@ func TestLogoutResolveArgMatchesProfileName(t *testing.T) { }, } - resolved, err := resolveLogoutArg(ctx, "dev", profiler) + resolved, err := resolveHostToProfile(ctx, "https://dev.cloud.databricks.com", 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) { +func TestResolveHostToProfileMatchesMultipleProfiles(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) profiler := profile.InMemoryProfiler{ Profiles: profile.Profiles{ @@ -299,13 +286,13 @@ func TestLogoutResolveArgMatchesHostWithMultipleProfiles(t *testing.T) { }, } - _, err := resolveLogoutArg(ctx, "https://shared.cloud.databricks.com", profiler) + _, err := resolveHostToProfile(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) { +func TestResolveHostToProfileMatchesNothing(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) profiler := profile.InMemoryProfiler{ Profiles: profile.Profiles{ @@ -314,13 +301,13 @@ func TestLogoutResolveArgMatchesNothing(t *testing.T) { }, } - _, err := resolveLogoutArg(ctx, "https://unknown.cloud.databricks.com", profiler) - assert.ErrorContains(t, err, `no profile found matching "https://unknown.cloud.databricks.com"`) + _, err := resolveHostToProfile(ctx, "https://unknown.cloud.databricks.com", profiler) + assert.ErrorContains(t, err, `no profile found matching host "https://unknown.cloud.databricks.com"`) assert.ErrorContains(t, err, "dev") assert.ErrorContains(t, err, "staging") } -func TestLogoutResolveArgCanonicalizesHost(t *testing.T) { +func TestResolveHostToProfileCanonicalizesHost(t *testing.T) { profiler := profile.InMemoryProfiler{ Profiles: profile.Profiles{ {Name: "dev", Host: "https://dev.cloud.databricks.com", AuthType: "databricks-cli"}, @@ -339,7 +326,7 @@ func TestLogoutResolveArgCanonicalizesHost(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) - resolved, err := resolveLogoutArg(ctx, tc.arg, profiler) + resolved, err := resolveHostToProfile(ctx, tc.arg, profiler) require.NoError(t, err) assert.Equal(t, "dev", resolved) }) @@ -347,9 +334,12 @@ func TestLogoutResolveArgCanonicalizesHost(t *testing.T) { } 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() + parent.AddCommand(cmd) + parent.SetArgs([]string{"logout", "myprofile", "--profile", "other"}) + err := parent.Execute() assert.ErrorContains(t, err, "providing both --profile and a positional argument is not supported") } diff --git a/cmd/auth/resolve.go b/cmd/auth/resolve.go index cf3a97646b..a6fc867dea 100644 --- a/cmd/auth/resolve.go +++ b/cmd/auth/resolve.go @@ -5,7 +5,9 @@ import ( "fmt" "strings" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/databrickscfg/profile" + "github.com/databricks/databricks-sdk-go/config" ) // looksLikeHost returns true if the argument looks like a host URL rather than @@ -35,3 +37,43 @@ func resolvePositionalArg(ctx context.Context, arg string, profiler profile.Prof return "", "", fmt.Errorf("no profile named %q found", arg) } + +// resolveHostToProfile resolves a host URL to a profile name. If multiple +// profiles match the host, it prompts the user to select one (or errors in +// non-interactive mode). If no profiles match, it returns an error. +func resolveHostToProfile(ctx context.Context, host string, profiler profile.Profiler) (string, error) { + canonicalHost := (&config.Config{Host: host}).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 host %q", host) + } + names := strings.Join(allProfiles.Names(), ", ") + return "", fmt.Errorf("no profile found matching host %q. Available profiles: %s", host, names) + default: + if cmdio.IsPromptSupported(ctx) { + selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ + Label: fmt.Sprintf("Multiple profiles found for %q. Select one", host), + 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", host, names) + } +} From 3ceec682f2ee6949b3ce166a7846585bb1621aac Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:55:27 +0100 Subject: [PATCH 6/9] Reword login error message, co-locate resolveHostToProfile tests Co-authored-by: Isaac --- cmd/auth/login.go | 2 +- cmd/auth/logout_test.go | 70 ---------------------------------------- cmd/auth/resolve_test.go | 70 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 71 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 2d7657d694..8eb96eaf22 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -165,7 +165,7 @@ depends on the existing profiles you have set in your configuration file // Resolve positional argument as profile or host. if len(args) > 0 && authArguments.Host != "" { - return errors.New("please only provide a host as an argument or a flag, not both") + return errors.New("please only provide a positional argument or --host, not both") } if profileName == "" && len(args) == 1 { resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args[0], profile.DefaultProfiler) diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go index 467a2e709c..2c0aa5abf9 100644 --- a/cmd/auth/logout_test.go +++ b/cmd/auth/logout_test.go @@ -263,76 +263,6 @@ func TestLogoutNoTokensWithDelete(t *testing.T) { assert.Empty(t, profiles) } -func TestResolveHostToProfileMatchesOneProfile(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 := resolveHostToProfile(ctx, "https://dev.cloud.databricks.com", profiler) - require.NoError(t, err) - assert.Equal(t, "dev", resolved) -} - -func TestResolveHostToProfileMatchesMultipleProfiles(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 := resolveHostToProfile(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 TestResolveHostToProfileMatchesNothing(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 := resolveHostToProfile(ctx, "https://unknown.cloud.databricks.com", profiler) - assert.ErrorContains(t, err, `no profile found matching host "https://unknown.cloud.databricks.com"`) - assert.ErrorContains(t, err, "dev") - assert.ErrorContains(t, err, "staging") -} - -func TestResolveHostToProfileCanonicalizesHost(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 := resolveHostToProfile(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") diff --git a/cmd/auth/resolve_test.go b/cmd/auth/resolve_test.go index 7a4a4e5441..d47d1d2ab1 100644 --- a/cmd/auth/resolve_test.go +++ b/cmd/auth/resolve_test.go @@ -90,3 +90,73 @@ func TestResolvePositionalArg(t *testing.T) { }) } } + +func TestResolveHostToProfileMatchesOneProfile(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 := resolveHostToProfile(ctx, "https://dev.cloud.databricks.com", profiler) + require.NoError(t, err) + assert.Equal(t, "dev", resolved) +} + +func TestResolveHostToProfileMatchesMultipleProfiles(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 := resolveHostToProfile(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 TestResolveHostToProfileMatchesNothing(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 := resolveHostToProfile(ctx, "https://unknown.cloud.databricks.com", profiler) + assert.ErrorContains(t, err, `no profile found matching host "https://unknown.cloud.databricks.com"`) + assert.ErrorContains(t, err, "dev") + assert.ErrorContains(t, err, "staging") +} + +func TestResolveHostToProfileCanonicalizesHost(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 := resolveHostToProfile(ctx, tc.arg, profiler) + require.NoError(t, err) + assert.Equal(t, "dev", resolved) + }) + } +} From 9d8370121b566a69384a7243f86b3a819e6bf045 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 22:58:33 +0100 Subject: [PATCH 7/9] Fix whitespace and lint in logout.go and resolve.go Co-authored-by: Isaac --- cmd/auth/logout.go | 1 - cmd/auth/resolve.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 254e451e6f..a6fcb551d4 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -300,4 +300,3 @@ func hostCacheKeyAndMatchFn(p profile.Profile) (string, profile.ProfileMatchFunc return host, profile.WithHost(host) } - diff --git a/cmd/auth/resolve.go b/cmd/auth/resolve.go index a6fc867dea..95ca3ea3ef 100644 --- a/cmd/auth/resolve.go +++ b/cmd/auth/resolve.go @@ -22,7 +22,7 @@ func looksLikeHost(arg string) bool { // matches and the argument looks like a host URL, it returns it as a host. If // no profile matches and the argument does not look like a host, it returns an // error. -func resolvePositionalArg(ctx context.Context, arg string, profiler profile.Profiler) (profileName string, host string, err error) { +func resolvePositionalArg(ctx context.Context, arg string, profiler profile.Profiler) (profileName, host string, err error) { candidateProfile, err := loadProfileByName(ctx, arg, profiler) if err != nil { return "", "", err From 1785a0a2fb565dace2d3f06cee8612ddb33e58ea Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 25 Mar 2026 23:47:02 +0100 Subject: [PATCH 8/9] Fix review findings for auth positional profile resolution - Fix token command skipping resolver when DATABRICKS_CONFIG_PROFILE is set by moving positional arg resolution before the env var read - Add test for login's --host + positional argument conflict guard - Align token command's Use string to PROFILE_OR_HOST for consistency - Add host:port detection (e.g., localhost:8080) to looksLikeHost - Improve resolveHostToProfile prompt label to "Select one to use" Co-authored-by: Isaac --- cmd/auth/login_test.go | 11 +++++++++++ cmd/auth/resolve.go | 14 ++++++++++++-- cmd/auth/resolve_test.go | 7 +++++++ cmd/auth/token.go | 23 +++++++++++++---------- cmd/auth/token_test.go | 14 ++++++++++++++ 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 670ff7c211..546c6651a2 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -813,3 +813,14 @@ auth_type = databricks-cli assert.Equal(t, "https://new-workspace.example.com", savedProfile.Host) assert.Equal(t, "222222", savedProfile.WorkspaceID, "workspace_id should be updated to fresh introspection value") } + +func TestLoginRejectsHostFlagWithPositionalArg(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, "please only provide a positional argument or --host, not both") +} diff --git a/cmd/auth/resolve.go b/cmd/auth/resolve.go index 95ca3ea3ef..7c0e477323 100644 --- a/cmd/auth/resolve.go +++ b/cmd/auth/resolve.go @@ -3,6 +3,7 @@ package auth import ( "context" "fmt" + "strconv" "strings" "github.com/databricks/cli/libs/cmdio" @@ -14,7 +15,16 @@ import ( // a profile name. Profile names are short identifiers (e.g., "logfood", // "DEFAULT"), while host URLs contain dots or start with "http". func looksLikeHost(arg string) bool { - return strings.Contains(arg, ".") || strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") + if strings.Contains(arg, ".") || strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") { + return true + } + // Match host:port pattern without dots or scheme (e.g., localhost:8080). + if i := strings.LastIndex(arg, ":"); i > 0 { + if _, err := strconv.Atoi(arg[i+1:]); err == nil { + return true + } + } + return false } // resolvePositionalArg resolves a positional argument to either a profile name @@ -61,7 +71,7 @@ func resolveHostToProfile(ctx context.Context, host string, profiler profile.Pro default: if cmdio.IsPromptSupported(ctx) { selected, err := profile.SelectProfile(ctx, profile.SelectConfig{ - Label: fmt.Sprintf("Multiple profiles found for %q. Select one", host), + Label: fmt.Sprintf("Multiple profiles found for %q. Select one to use", host), Profiles: hostProfiles, StartInSearchMode: len(hostProfiles) > 5, ActiveTemplate: "▸ {{.PaddedName | bold}}{{if .AccountID}} (account: {{.AccountID}}){{else}} ({{.Host}}){{end}}", diff --git a/cmd/auth/resolve_test.go b/cmd/auth/resolve_test.go index d47d1d2ab1..1180b22038 100644 --- a/cmd/auth/resolve_test.go +++ b/cmd/auth/resolve_test.go @@ -58,6 +58,13 @@ func TestResolvePositionalArg(t *testing.T) { wantProfile: "", wantHost: "http://localhost:8080", }, + { + name: "host:port without dots or scheme", + profiles: profile.Profiles{}, + arg: "localhost:8080", + wantProfile: "", + wantHost: "localhost:8080", + }, { name: "empty profiles error", profiles: profile.Profiles{}, diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 3008e0172b..33f17419de 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -52,7 +52,7 @@ func applyUnifiedHostFlags(p *profile.Profile, args *auth.AuthArguments) { func newTokenCommand(authArguments *auth.AuthArguments) *cobra.Command { cmd := &cobra.Command{ - Use: "token [HOST_OR_PROFILE]", + Use: "token [PROFILE_OR_HOST]", Short: "Get authentication token", Long: `Get authentication token from the local cache in ~/.databricks/token-cache.json. Refresh the access token if it is expired. Note: This command only works with @@ -123,15 +123,10 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { return nil, errors.New("providing both a profile and host is not supported") } - // When no explicit --profile flag is provided, check the env var. This - // handles the case where downstream tools (like the Terraform provider) - // pass --host but not --profile, while DATABRICKS_CONFIG_PROFILE is set. - if args.profileName == "" { - args.profileName = env.Get(ctx, "DATABRICKS_CONFIG_PROFILE") - } - - // If no --profile flag, resolve the positional arg as a profile name first, - // then as a host. Error if it matches neither. + // Resolve the positional arg as a profile name first, then as a host. + // Error if it matches neither. This runs before the DATABRICKS_CONFIG_PROFILE + // env var check so that an explicit positional argument always goes through + // profile-first resolution. if args.profileName == "" && len(args.args) == 1 { resolvedProfile, resolvedHost, err := resolvePositionalArg(ctx, args.args[0], args.profiler) if err != nil { @@ -146,6 +141,14 @@ func loadToken(ctx context.Context, args loadTokenArgs) (*oauth2.Token, error) { } } + // When no explicit --profile flag or positional arg is provided, check the + // env var. This handles the case where downstream tools (like the Terraform + // provider) pass --host but not --profile, while DATABRICKS_CONFIG_PROFILE + // is set. + if args.profileName == "" { + args.profileName = env.Get(ctx, "DATABRICKS_CONFIG_PROFILE") + } + existingProfile, err := loadProfileByName(ctx, args.profileName, args.profiler) if err != nil { return nil, err diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index edcfb17f67..a85373ca8c 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -689,6 +689,20 @@ func TestToken_loadToken(t *testing.T) { }, validateToken: validateToken, }, + { + name: "DATABRICKS_CONFIG_PROFILE with positional typo runs resolver first", + setupCtx: func(ctx context.Context) context.Context { + return env.Set(ctx, "DATABRICKS_CONFIG_PROFILE", "active") + }, + args: loadTokenArgs{ + authArguments: &auth.AuthArguments{}, + profileName: "", + args: []string{"e2-logfood"}, + tokenTimeout: 1 * time.Hour, + profiler: profiler, + }, + wantErr: `no profile named "e2-logfood" found`, + }, { name: "host flag with profile env var disambiguates multi-profile", setupCtx: func(ctx context.Context) context.Context { From eff50c4dbdf8c2fddfe36525dee611a590e3c8fa Mon Sep 17 00:00:00 2001 From: simon Date: Thu, 26 Mar 2026 00:01:37 +0100 Subject: [PATCH 9/9] Add NEXT_CHANGELOG entry for auth positional profile resolution Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 85a50d00b9..2d3d41fa1c 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -5,6 +5,7 @@ ### Notable Changes ### CLI +* Auth commands now resolve positional arguments as profile names first, with host fallback ([#4840](https://github.com/databricks/cli/pull/4840)) ### Bundles * engine/direct: Fix drift in grants resource due to privilege reordering ([#4794](https://github.com/databricks/cli/pull/4794))