diff --git a/cmd/project/create.go b/cmd/project/create.go index b869abc8..d4be7ede 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -17,9 +17,12 @@ package project import ( "context" "fmt" + "math/rand" "path/filepath" "strings" + "time" + "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/logger" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" @@ -134,6 +137,25 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] return err } + // Prompt for app name if not provided via flag or argument + if appNameArg == "" { + if clients.IO.IsTTY() { + defaultName := generateRandomAppName() + cmd.Print(style.Secondary(fmt.Sprintf(" Press Enter to use the generated name: %s", defaultName)), "\n") + name, err := clients.IO.InputPrompt(ctx, "Name your app:", iostreams.InputPromptConfig{}) + if err != nil { + return err + } + if name != "" { + appNameArg = name + } else { + appNameArg = defaultName + } + } else { + appNameArg = generateRandomAppName() + } + } + // Set up spinners appCreateSpinner = style.NewSpinner(cmd.OutOrStdout()) @@ -277,6 +299,15 @@ func printCreateSuccess(ctx context.Context, clients *shared.ClientFactory, appP clients.IO.PrintTrace(ctx, slacktrace.CreateSuccess) } +// generateRandomAppName will create a random app name based on two words and a number +func generateRandomAppName() string { + rand.New(rand.NewSource(time.Now().UnixNano())) + var firstRandomNum = rand.Intn(len(create.Adjectives)) + var secondRandomNum = rand.Intn(len(create.Animals)) + var randomName = fmt.Sprintf("%s-%s-%d", create.Adjectives[firstRandomNum], create.Animals[secondRandomNum], rand.Intn(1000)) + return randomName +} + // printAppCreateError stops the creation spinners and displays the returned error message func printAppCreateError(clients *shared.ClientFactory, cmd *cobra.Command, err error) { ctx := cmd.Context() diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 2874643c..5109d6a5 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -46,6 +46,7 @@ func TestCreateCommand(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ "creates a bolt application from prompts": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). Return( iostreams.SelectPromptResponse{ @@ -62,6 +63,8 @@ func TestCreateCommand(t *testing.T) { }, nil, ) + cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything). + Return("my-app", nil) createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) CreateFunc = createClientMock.Create @@ -70,14 +73,17 @@ func TestCreateCommand(t *testing.T) { template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template") require.NoError(t, err) expected := create.CreateArgs{ + AppName: "my-app", Template: template, } createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates a deno application from flags": { CmdArgs: []string{"--template", "slack-samples/deno-starter-template"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). Return( iostreams.SelectPromptResponse{ @@ -94,6 +100,8 @@ func TestCreateCommand(t *testing.T) { }, nil, ) + cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything). + Return("my-deno-app", nil) createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) CreateFunc = createClientMock.Create @@ -102,14 +110,17 @@ func TestCreateCommand(t *testing.T) { template, err := create.ResolveTemplateURL("slack-samples/deno-starter-template") require.NoError(t, err) expected := create.CreateArgs{ + AppName: "my-deno-app", Template: template, } createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates an agent app using agent argument shortcut": { CmdArgs: []string{"agent"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) // Should skip category prompt and go directly to language selection cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything). Return( @@ -119,6 +130,8 @@ func TestCreateCommand(t *testing.T) { }, nil, ) + cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything). + Return("my-agent", nil) createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) CreateFunc = createClientMock.Create @@ -127,11 +140,13 @@ func TestCreateCommand(t *testing.T) { template, err := create.ResolveTemplateURL("slack-samples/bolt-js-assistant-template") require.NoError(t, err) expected := create.CreateArgs{ + AppName: "my-agent", Template: template, } createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) // Verify that category prompt was NOT called cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) + cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates an agent app with app name using agent argument": { @@ -160,6 +175,8 @@ func TestCreateCommand(t *testing.T) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) // Verify that category prompt was NOT called cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) + // Verify that name prompt was NOT called since name was provided as arg + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates an app named agent when template flag is provided": { @@ -193,6 +210,8 @@ func TestCreateCommand(t *testing.T) { Template: template, } createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + // Verify that name prompt was NOT called since name was provided as arg + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates an app named agent using name flag without triggering shortcut": { @@ -229,6 +248,8 @@ func TestCreateCommand(t *testing.T) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) // Verify that category prompt WAS called (shortcut was not triggered) cm.IO.AssertCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) + // Verify that name prompt was NOT called since --name flag was provided + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "creates an agent app with name flag overriding positional arg": { @@ -290,6 +311,8 @@ func TestCreateCommand(t *testing.T) { Template: template, } createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + // Verify that name prompt was NOT called since --name flag was provided + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "name flag overrides positional app name argument with agent shortcut": { @@ -318,6 +341,110 @@ func TestCreateCommand(t *testing.T) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) // Verify that category prompt was NOT called (agent shortcut was triggered) cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything) + // Verify that name prompt was NOT called since --name flag was provided + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) + }, + }, + "user accepts default name from prompt": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsTTY").Return(true) + cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Prompt: true, + Index: 0, + }, + nil, + ) + cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Prompt: true, + Index: 0, + }, + nil, + ) + // Return empty string to simulate pressing Enter (accepting default) + cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything). + Return("", nil) + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) + // When the user accepts the default (empty return), the generated name is used + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool { + return args.AppName != "" + })) + }, + }, + "non-TTY without name falls back to generated name": { + CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + // IsTTY defaults to false via AddDefaultMocks, simulating piped output + cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Flag: true, + Option: "slack-samples/bolt-js-starter-template", + }, + nil, + ) + cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Flag: true, + Option: "slack-samples/bolt-js-starter-template", + }, + nil, + ) + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + // Should NOT prompt for name since not a TTY + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) + // Should still call Create with a non-empty generated name + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool { + return args.AppName != "" + })) + }, + }, + "positional arg skips name prompt": { + CmdArgs: []string{"my-project"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Prompt: true, + Index: 0, + }, + nil, + ) + cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything). + Return( + iostreams.SelectPromptResponse{ + Prompt: true, + Index: 0, + }, + nil, + ) + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template") + require.NoError(t, err) + expected := create.CreateArgs{ + AppName: "my-project", + Template: template, + } + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected) + // Verify that name prompt was NOT called since name was provided as positional arg + cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, "lists all templates with --list flag": { diff --git a/internal/pkg/create/constants.go b/internal/pkg/create/constants.go index 992e30d3..2ba866cf 100644 --- a/internal/pkg/create/constants.go +++ b/internal/pkg/create/constants.go @@ -14,7 +14,7 @@ package create -var adjectives = []string{ +var Adjectives = []string{ "admiring", "adoring", "affectionate", @@ -113,7 +113,7 @@ var adjectives = []string{ "zen", } -var animals = []string{ +var Animals = []string{ "aardvark", "alligator", "alpaca", diff --git a/internal/pkg/create/create.go b/internal/pkg/create/create.go index 6e37f41b..0011867f 100644 --- a/internal/pkg/create/create.go +++ b/internal/pkg/create/create.go @@ -18,13 +18,11 @@ import ( "context" "fmt" "io" - "math/rand" "net/http" "os" "os/exec" "path/filepath" "strings" - "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -150,23 +148,15 @@ func Create(ctx context.Context, clients *shared.ClientFactory, log *logger.Logg return appDirPath, nil } -// generateRandomAppName will create a random app name based on two words and a number -func generateRandomAppName() string { - rand.New(rand.NewSource(time.Now().UnixNano())) - var firstRandomNum = rand.Intn(len(adjectives)) - var secondRandomNum = rand.Intn(len(animals)) - var randomName = fmt.Sprintf("%s-%s-%d", adjectives[firstRandomNum], animals[secondRandomNum], rand.Intn(1000)) - return randomName -} - // getAppDirName will validate and return the app's directory name func getAppDirName(appName string) (string, error) { if len(appName) <= 0 { - return generateRandomAppName(), nil + return "", fmt.Errorf("app name is required") } // trim whitespace - appName = strings.ReplaceAll(appName, " ", "") + appName = strings.TrimSpace(appName) + appName = strings.ReplaceAll(appName, " ", "-") // name cannot be a reserved word if goutils.Contains(reserved, appName, false) { diff --git a/internal/pkg/create/create_test.go b/internal/pkg/create/create_test.go index 67b8991d..13c30d42 100644 --- a/internal/pkg/create/create_test.go +++ b/internal/pkg/create/create_test.go @@ -39,16 +39,30 @@ func TestGetProjectDirectoryName(t *testing.T) { var appName string var err error - // Test without app name test removed because more than one possible default name + // Test with empty name returns an error + appName, err = getAppDirName("") + assert.Error(t, err, "should return an error for empty name") + assert.Equal(t, "", appName) + // Test with app name appName, err = getAppDirName("my-app") assert.NoError(t, err, "should not return an error") - assert.Equal(t, appName, "my-app", "should return 'my-app'") + assert.Equal(t, "my-app", appName, "should return 'my-app'") // Test with a dot in the app name appName, err = getAppDirName(".my-app") assert.NoError(t, err, "should not return an error") - assert.Equal(t, appName, ".my-app", "should return '.my-app'") + assert.Equal(t, ".my-app", appName, "should return '.my-app'") + + // Spaces replaced with hyphens + appName, err = getAppDirName("my cool app") + assert.NoError(t, err) + assert.Equal(t, "my-cool-app", appName) + + // Leading/trailing spaces trimmed + appName, err = getAppDirName(" my-app ") + assert.NoError(t, err) + assert.Equal(t, "my-app", appName) } func TestGetAvailableDirectory(t *testing.T) {