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
31 changes: 31 additions & 0 deletions cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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()
Expand Down
127 changes: 127 additions & 0 deletions cmd/project/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines 47 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 suggestion: We might want to include a test case alongside af97eca changes - do we have a unit test that's checking for --template arguments while IsTTY is also set to false?

cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
Return(
iostreams.SelectPromptResponse{
Expand All @@ -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
Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions internal/pkg/create/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

package create

var adjectives = []string{
var Adjectives = []string{
"admiring",
"adoring",
"affectionate",
Expand Down Expand Up @@ -113,7 +113,7 @@ var adjectives = []string{
"zen",
}

var animals = []string{
var Animals = []string{
"aardvark",
"alligator",
"alpaca",
Expand Down
16 changes: 3 additions & 13 deletions internal/pkg/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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, " ", "-")
Comment on lines 168 to +159
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📣 note: Let's call this out in the changelog as a fix as well! I'll make a few changes to this since we'll also want to keep it focused on just developer-facing features-


// name cannot be a reserved word
if goutils.Contains(reserved, appName, false) {
Expand Down
20 changes: 17 additions & 3 deletions internal/pkg/create/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading