diff --git a/acceptance/apps/deploy/no-bundle-no-args/output.txt b/acceptance/apps/deploy/no-bundle-no-args/output.txt index affcf94d8f5..0401016dacf 100644 --- a/acceptance/apps/deploy/no-bundle-no-args/output.txt +++ b/acceptance/apps/deploy/no-bundle-no-args/output.txt @@ -1,3 +1,9 @@ -Error: accepts 1 arg(s), received 0 +Error: missing required argument: APP_NAME + +Usage: databricks apps deploy APP_NAME + +APP_NAME is the name of the Databricks app to operate on. +Alternatively, run this command from a project directory containing +databricks.yml to auto-detect the app name. Exit code: 1 diff --git a/cmd/apps/bundle_helpers.go b/cmd/apps/bundle_helpers.go index 6b1712de563..d1e587bebca 100644 --- a/cmd/apps/bundle_helpers.go +++ b/cmd/apps/bundle_helpers.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strings" "time" @@ -27,12 +29,71 @@ func makeArgsOptionalWithBundle(cmd *cobra.Command, usage string) { return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args)) } if !hasBundleConfig() && len(args) != 1 { - return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) + return missingAppNameError(cmd) } return nil } } +// missingAppNameError returns an error message that explains what the positional +// argument should be, and attempts to infer a suggestion from the local environment. +// The full subcommand path (e.g. "databricks apps start") is rendered from cmd so +// the usage line and "Did you mean?" hint match the verb the user actually ran. +func missingAppNameError(cmd *cobra.Command) error { + hint := inferAppNameHint() + commandPath := "databricks apps " + argName := "APP_NAME" + if cmd != nil { + if p := cmd.CommandPath(); p != "" { + commandPath = p + } + if name := positionalArgName(cmd.Use); name != "" { + argName = name + } + } + msg := fmt.Sprintf(`missing required argument: %s + +Usage: %s %s + +%s is the name of the Databricks app to operate on. +Alternatively, run this command from a project directory containing +databricks.yml to auto-detect the app name.`, argName, commandPath, argName, argName) + + if hint != "" { + msg += fmt.Sprintf("\n\nDid you mean?\n %s %s", commandPath, hint) + } + + return errors.New(msg) +} + +func positionalArgName(use string) string { + start := strings.Index(use, "[") + end := strings.Index(use, "]") + if start < 0 || end <= start { + return "" + } + return use[start+1 : end] +} + +// inferAppNameHint tries to suggest an app name from the local environment. +// Only returns a hint if the current directory looks like a Databricks app +// (contains app.yml or app.yaml), using the directory name as the suggestion. +func inferAppNameHint() string { + wd, err := os.Getwd() + if err != nil { + return "" + } + + for _, filename := range []string{"app.yml", "app.yaml"} { + info, err := os.Stat(filepath.Join(wd, filename)) + if err == nil && info.Mode().IsRegular() { + return filepath.Base(wd) + } + } + + return "" +} + // getAppNameFromArgs returns the app name from args or detects it from the bundle. // Returns (appName, fromBundle, error). func getAppNameFromArgs(cmd *cobra.Command, args []string) (string, bool, error) { diff --git a/cmd/apps/bundle_helpers_test.go b/cmd/apps/bundle_helpers_test.go index f772d4f545e..9e798d0f830 100644 --- a/cmd/apps/bundle_helpers_test.go +++ b/cmd/apps/bundle_helpers_test.go @@ -2,6 +2,8 @@ package apps import ( "errors" + "os" + "path/filepath" "testing" "github.com/databricks/databricks-sdk-go/service/apps" @@ -105,6 +107,112 @@ func TestFormatAppStatusMessage(t *testing.T) { }) } +func TestInferAppNameHint(t *testing.T) { + t.Run("returns empty when no app config exists", func(t *testing.T) { + t.Chdir(t.TempDir()) + + assert.Equal(t, "", inferAppNameHint()) + }) + + t.Run("returns dir name when app.yml exists", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + err := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644) + assert.NoError(t, err) + + assert.Equal(t, filepath.Base(dir), inferAppNameHint()) + }) + + t.Run("returns dir name when app.yaml exists", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("command: [\"python\"]"), 0o644) + assert.NoError(t, err) + + assert.Equal(t, filepath.Base(dir), inferAppNameHint()) + }) + + t.Run("returns empty when cwd has been deleted", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + os.Remove(dir) + + assert.Equal(t, "", inferAppNameHint()) + }) +} + +func TestMissingAppNameError(t *testing.T) { + t.Run("includes APP_NAME and usage info", func(t *testing.T) { + t.Chdir(t.TempDir()) + + err := missingAppNameError(nil) + assert.Contains(t, err.Error(), "APP_NAME") + assert.Contains(t, err.Error(), "databricks.yml") + assert.NotContains(t, err.Error(), "Did you mean") + }) + + t.Run("includes hint when app.yml exists", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeErr := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644) + assert.NoError(t, writeErr) + + err := missingAppNameError(nil) + assert.Contains(t, err.Error(), "Did you mean") + assert.Contains(t, err.Error(), filepath.Base(dir)) + }) + + t.Run("gracefully handles deleted cwd", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + os.Remove(dir) + + err := missingAppNameError(nil) + assert.Contains(t, err.Error(), "APP_NAME") + assert.NotContains(t, err.Error(), "Did you mean") + }) + + t.Run("renders usage and hint from cmd path per verb", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeErr := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644) + assert.NoError(t, writeErr) + + for _, tc := range []struct { + verb string + use string + arg string + }{ + {"deploy", "deploy [APP_NAME]", "APP_NAME"}, + {"start", "start [NAME]", "NAME"}, + {"stop", "stop [NAME]", "NAME"}, + {"delete", "delete [NAME]", "NAME"}, + } { + t.Run(tc.verb, func(t *testing.T) { + root := &cobra.Command{Use: "databricks"} + apps := &cobra.Command{Use: "apps"} + sub := &cobra.Command{Use: tc.use} + root.AddCommand(apps) + apps.AddCommand(sub) + + err := missingAppNameError(sub) + assert.Contains(t, err.Error(), "missing required argument: "+tc.arg) + assert.Contains(t, err.Error(), "Usage: databricks apps "+tc.verb+" "+tc.arg) + assert.Contains(t, err.Error(), "databricks apps "+tc.verb+" "+filepath.Base(dir)) + }) + } + }) + + t.Run("ignores non-regular app.yml entries", func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + assert.NoError(t, os.Mkdir(filepath.Join(dir, "app.yml"), 0o755)) + + err := missingAppNameError(nil) + assert.NotContains(t, err.Error(), "Did you mean") + }) +} + func TestMakeArgsOptionalWithBundle(t *testing.T) { t.Run("updates command usage", func(t *testing.T) { cmd := &cobra.Command{} @@ -117,6 +225,17 @@ func TestMakeArgsOptionalWithBundle(t *testing.T) { makeArgsOptionalWithBundle(cmd, "test [NAME]") assert.NotNil(t, cmd.Args) }) + + t.Run("returns missing app name error when no bundle config exists", func(t *testing.T) { + t.Chdir(t.TempDir()) + + cmd := &cobra.Command{} + makeArgsOptionalWithBundle(cmd, "test [NAME]") + + err := cmd.Args(cmd, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required argument: NAME") + }) } func TestGetAppNameFromArgs(t *testing.T) { diff --git a/cmd/apps/logs.go b/cmd/apps/logs.go index c6cab936521..777a9f3621b 100644 --- a/cmd/apps/logs.go +++ b/cmd/apps/logs.go @@ -46,7 +46,6 @@ func newLogsCommand() *cobra.Command { ) cmd := &cobra.Command{ - Use: "logs [NAME]", Short: "Show Databricks app logs", Long: `Show Databricks app logs. @@ -78,15 +77,6 @@ Examples: # Mirror streamed logs to a local file while following for up to 5 minutes databricks apps logs my-app --follow --timeout 5m --output-file /tmp/my-app.log`, - Args: func(cmd *cobra.Command, args []string) error { - if len(args) > 1 { - return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args)) - } - if !hasBundleConfig() && len(args) != 1 { - return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) - } - return nil - }, PreRunE: root.MustWorkspaceClient, RunE: func(cmd *cobra.Command, args []string) error { appName, fromBundle, err := getAppNameFromArgs(cmd, args) @@ -207,6 +197,7 @@ Examples: }) }, } + makeArgsOptionalWithBundle(cmd, "logs [NAME]") streamGroup := cmdgroup.NewFlagGroup("Streaming") streamGroup.FlagSet().IntVar(&tailLines, "tail-lines", defaultTailLines, "Number of recent log lines to show before streaming. Set to 0 to show everything.") diff --git a/cmd/apps/logs_test.go b/cmd/apps/logs_test.go index ca7ea5e7ef9..2f8d74b11a7 100644 --- a/cmd/apps/logs_test.go +++ b/cmd/apps/logs_test.go @@ -64,6 +64,17 @@ func TestBuildLogsURLRejectsUnknownScheme(t *testing.T) { require.Error(t, err) } +func TestLogsMissingNameError(t *testing.T) { + t.Chdir(t.TempDir()) + + cmd := newLogsCommand() + err := cmd.Args(cmd, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "missing required argument: NAME") + assert.Contains(t, err.Error(), "Usage: logs NAME") +} + func TestNormalizeOrigin(t *testing.T) { assert.Equal(t, "https://example.com", normalizeOrigin("https://example.com/foo")) assert.Equal(t, "http://example.com", normalizeOrigin("ws://example.com/foo"))