Skip to content

Commit 17f686e

Browse files
srtaalejzimeg
andauthored
feat(create): replace the generated default app name with an optional input prompt (#340)
Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent a2df7c6 commit 17f686e

5 files changed

Lines changed: 180 additions & 18 deletions

File tree

cmd/project/create.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ package project
1717
import (
1818
"context"
1919
"fmt"
20+
"math/rand"
2021
"path/filepath"
2122
"strings"
23+
"time"
2224

25+
"github.com/slackapi/slack-cli/internal/iostreams"
2326
"github.com/slackapi/slack-cli/internal/logger"
2427
"github.com/slackapi/slack-cli/internal/pkg/create"
2528
"github.com/slackapi/slack-cli/internal/shared"
@@ -134,6 +137,25 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
134137
return err
135138
}
136139

140+
// Prompt for app name if not provided via flag or argument
141+
if appNameArg == "" {
142+
if clients.IO.IsTTY() {
143+
defaultName := generateRandomAppName()
144+
cmd.Print(style.Secondary(fmt.Sprintf(" Press Enter to use the generated name: %s", defaultName)), "\n")
145+
name, err := clients.IO.InputPrompt(ctx, "Name your app:", iostreams.InputPromptConfig{})
146+
if err != nil {
147+
return err
148+
}
149+
if name != "" {
150+
appNameArg = name
151+
} else {
152+
appNameArg = defaultName
153+
}
154+
} else {
155+
appNameArg = generateRandomAppName()
156+
}
157+
}
158+
137159
// Set up spinners
138160
appCreateSpinner = style.NewSpinner(cmd.OutOrStdout())
139161

@@ -277,6 +299,15 @@ func printCreateSuccess(ctx context.Context, clients *shared.ClientFactory, appP
277299
clients.IO.PrintTrace(ctx, slacktrace.CreateSuccess)
278300
}
279301

302+
// generateRandomAppName will create a random app name based on two words and a number
303+
func generateRandomAppName() string {
304+
rand.New(rand.NewSource(time.Now().UnixNano()))
305+
var firstRandomNum = rand.Intn(len(create.Adjectives))
306+
var secondRandomNum = rand.Intn(len(create.Animals))
307+
var randomName = fmt.Sprintf("%s-%s-%d", create.Adjectives[firstRandomNum], create.Animals[secondRandomNum], rand.Intn(1000))
308+
return randomName
309+
}
310+
280311
// printAppCreateError stops the creation spinners and displays the returned error message
281312
func printAppCreateError(clients *shared.ClientFactory, cmd *cobra.Command, err error) {
282313
ctx := cmd.Context()

cmd/project/create_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func TestCreateCommand(t *testing.T) {
4646
testutil.TableTestCommand(t, testutil.CommandTests{
4747
"creates a bolt application from prompts": {
4848
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
49+
cm.IO.On("IsTTY").Return(true)
4950
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
5051
Return(
5152
iostreams.SelectPromptResponse{
@@ -62,6 +63,8 @@ func TestCreateCommand(t *testing.T) {
6263
},
6364
nil,
6465
)
66+
cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything).
67+
Return("my-app", nil)
6568
createClientMock = new(CreateClientMock)
6669
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
6770
CreateFunc = createClientMock.Create
@@ -70,14 +73,17 @@ func TestCreateCommand(t *testing.T) {
7073
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
7174
require.NoError(t, err)
7275
expected := create.CreateArgs{
76+
AppName: "my-app",
7377
Template: template,
7478
}
7579
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
80+
cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
7681
},
7782
},
7883
"creates a deno application from flags": {
7984
CmdArgs: []string{"--template", "slack-samples/deno-starter-template"},
8085
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
86+
cm.IO.On("IsTTY").Return(true)
8187
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
8288
Return(
8389
iostreams.SelectPromptResponse{
@@ -94,6 +100,8 @@ func TestCreateCommand(t *testing.T) {
94100
},
95101
nil,
96102
)
103+
cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything).
104+
Return("my-deno-app", nil)
97105
createClientMock = new(CreateClientMock)
98106
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
99107
CreateFunc = createClientMock.Create
@@ -102,14 +110,17 @@ func TestCreateCommand(t *testing.T) {
102110
template, err := create.ResolveTemplateURL("slack-samples/deno-starter-template")
103111
require.NoError(t, err)
104112
expected := create.CreateArgs{
113+
AppName: "my-deno-app",
105114
Template: template,
106115
}
107116
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
117+
cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
108118
},
109119
},
110120
"creates an agent app using agent argument shortcut": {
111121
CmdArgs: []string{"agent"},
112122
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
123+
cm.IO.On("IsTTY").Return(true)
113124
// Should skip category prompt and go directly to language selection
114125
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
115126
Return(
@@ -119,6 +130,8 @@ func TestCreateCommand(t *testing.T) {
119130
},
120131
nil,
121132
)
133+
cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything).
134+
Return("my-agent", nil)
122135
createClientMock = new(CreateClientMock)
123136
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
124137
CreateFunc = createClientMock.Create
@@ -127,11 +140,13 @@ func TestCreateCommand(t *testing.T) {
127140
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-assistant-template")
128141
require.NoError(t, err)
129142
expected := create.CreateArgs{
143+
AppName: "my-agent",
130144
Template: template,
131145
}
132146
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
133147
// Verify that category prompt was NOT called
134148
cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything)
149+
cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
135150
},
136151
},
137152
"creates an agent app with app name using agent argument": {
@@ -160,6 +175,8 @@ func TestCreateCommand(t *testing.T) {
160175
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
161176
// Verify that category prompt was NOT called
162177
cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything)
178+
// Verify that name prompt was NOT called since name was provided as arg
179+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
163180
},
164181
},
165182
"creates an app named agent when template flag is provided": {
@@ -193,6 +210,8 @@ func TestCreateCommand(t *testing.T) {
193210
Template: template,
194211
}
195212
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
213+
// Verify that name prompt was NOT called since name was provided as arg
214+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
196215
},
197216
},
198217
"creates an app named agent using name flag without triggering shortcut": {
@@ -229,6 +248,8 @@ func TestCreateCommand(t *testing.T) {
229248
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
230249
// Verify that category prompt WAS called (shortcut was not triggered)
231250
cm.IO.AssertCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything)
251+
// Verify that name prompt was NOT called since --name flag was provided
252+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
232253
},
233254
},
234255
"creates an agent app with name flag overriding positional arg": {
@@ -290,6 +311,8 @@ func TestCreateCommand(t *testing.T) {
290311
Template: template,
291312
}
292313
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
314+
// Verify that name prompt was NOT called since --name flag was provided
315+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
293316
},
294317
},
295318
"name flag overrides positional app name argument with agent shortcut": {
@@ -318,6 +341,110 @@ func TestCreateCommand(t *testing.T) {
318341
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
319342
// Verify that category prompt was NOT called (agent shortcut was triggered)
320343
cm.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything)
344+
// Verify that name prompt was NOT called since --name flag was provided
345+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
346+
},
347+
},
348+
"user accepts default name from prompt": {
349+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
350+
cm.IO.On("IsTTY").Return(true)
351+
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
352+
Return(
353+
iostreams.SelectPromptResponse{
354+
Prompt: true,
355+
Index: 0,
356+
},
357+
nil,
358+
)
359+
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
360+
Return(
361+
iostreams.SelectPromptResponse{
362+
Prompt: true,
363+
Index: 0,
364+
},
365+
nil,
366+
)
367+
// Return empty string to simulate pressing Enter (accepting default)
368+
cm.IO.On("InputPrompt", mock.Anything, "Name your app:", mock.Anything).
369+
Return("", nil)
370+
createClientMock = new(CreateClientMock)
371+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
372+
CreateFunc = createClientMock.Create
373+
},
374+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
375+
cm.IO.AssertCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
376+
// When the user accepts the default (empty return), the generated name is used
377+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
378+
return args.AppName != ""
379+
}))
380+
},
381+
},
382+
"non-TTY without name falls back to generated name": {
383+
CmdArgs: []string{"--template", "slack-samples/bolt-js-starter-template"},
384+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
385+
// IsTTY defaults to false via AddDefaultMocks, simulating piped output
386+
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
387+
Return(
388+
iostreams.SelectPromptResponse{
389+
Flag: true,
390+
Option: "slack-samples/bolt-js-starter-template",
391+
},
392+
nil,
393+
)
394+
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
395+
Return(
396+
iostreams.SelectPromptResponse{
397+
Flag: true,
398+
Option: "slack-samples/bolt-js-starter-template",
399+
},
400+
nil,
401+
)
402+
createClientMock = new(CreateClientMock)
403+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
404+
CreateFunc = createClientMock.Create
405+
},
406+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
407+
// Should NOT prompt for name since not a TTY
408+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
409+
// Should still call Create with a non-empty generated name
410+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(args create.CreateArgs) bool {
411+
return args.AppName != ""
412+
}))
413+
},
414+
},
415+
"positional arg skips name prompt": {
416+
CmdArgs: []string{"my-project"},
417+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
418+
cm.IO.On("SelectPrompt", mock.Anything, "Select an app:", mock.Anything, mock.Anything).
419+
Return(
420+
iostreams.SelectPromptResponse{
421+
Prompt: true,
422+
Index: 0,
423+
},
424+
nil,
425+
)
426+
cm.IO.On("SelectPrompt", mock.Anything, "Select a language:", mock.Anything, mock.Anything).
427+
Return(
428+
iostreams.SelectPromptResponse{
429+
Prompt: true,
430+
Index: 0,
431+
},
432+
nil,
433+
)
434+
createClientMock = new(CreateClientMock)
435+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return("", nil)
436+
CreateFunc = createClientMock.Create
437+
},
438+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
439+
template, err := create.ResolveTemplateURL("slack-samples/bolt-js-starter-template")
440+
require.NoError(t, err)
441+
expected := create.CreateArgs{
442+
AppName: "my-project",
443+
Template: template,
444+
}
445+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything, expected)
446+
// Verify that name prompt was NOT called since name was provided as positional arg
447+
cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything)
321448
},
322449
},
323450
"lists all templates with --list flag": {

internal/pkg/create/constants.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
package create
1616

17-
var adjectives = []string{
17+
var Adjectives = []string{
1818
"admiring",
1919
"adoring",
2020
"affectionate",
@@ -113,7 +113,7 @@ var adjectives = []string{
113113
"zen",
114114
}
115115

116-
var animals = []string{
116+
var Animals = []string{
117117
"aardvark",
118118
"alligator",
119119
"alpaca",

internal/pkg/create/create.go

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ import (
1818
"context"
1919
"fmt"
2020
"io"
21-
"math/rand"
2221
"net/http"
2322
"os"
2423
"os/exec"
2524
"path/filepath"
2625
"strings"
27-
"time"
2826

2927
"github.com/go-git/go-git/v5"
3028
"github.com/go-git/go-git/v5/plumbing"
@@ -150,23 +148,15 @@ func Create(ctx context.Context, clients *shared.ClientFactory, log *logger.Logg
150148
return appDirPath, nil
151149
}
152150

153-
// generateRandomAppName will create a random app name based on two words and a number
154-
func generateRandomAppName() string {
155-
rand.New(rand.NewSource(time.Now().UnixNano()))
156-
var firstRandomNum = rand.Intn(len(adjectives))
157-
var secondRandomNum = rand.Intn(len(animals))
158-
var randomName = fmt.Sprintf("%s-%s-%d", adjectives[firstRandomNum], animals[secondRandomNum], rand.Intn(1000))
159-
return randomName
160-
}
161-
162151
// getAppDirName will validate and return the app's directory name
163152
func getAppDirName(appName string) (string, error) {
164153
if len(appName) <= 0 {
165-
return generateRandomAppName(), nil
154+
return "", fmt.Errorf("app name is required")
166155
}
167156

168157
// trim whitespace
169-
appName = strings.ReplaceAll(appName, " ", "")
158+
appName = strings.TrimSpace(appName)
159+
appName = strings.ReplaceAll(appName, " ", "-")
170160

171161
// name cannot be a reserved word
172162
if goutils.Contains(reserved, appName, false) {

internal/pkg/create/create_test.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,30 @@ func TestGetProjectDirectoryName(t *testing.T) {
4040
var appName string
4141
var err error
4242

43-
// Test without app name test removed because more than one possible default name
43+
// Test with empty name returns an error
44+
appName, err = getAppDirName("")
45+
assert.Error(t, err, "should return an error for empty name")
46+
assert.Equal(t, "", appName)
47+
4448
// Test with app name
4549
appName, err = getAppDirName("my-app")
4650
assert.NoError(t, err, "should not return an error")
47-
assert.Equal(t, appName, "my-app", "should return 'my-app'")
51+
assert.Equal(t, "my-app", appName, "should return 'my-app'")
4852

4953
// Test with a dot in the app name
5054
appName, err = getAppDirName(".my-app")
5155
assert.NoError(t, err, "should not return an error")
52-
assert.Equal(t, appName, ".my-app", "should return '.my-app'")
56+
assert.Equal(t, ".my-app", appName, "should return '.my-app'")
57+
58+
// Spaces replaced with hyphens
59+
appName, err = getAppDirName("my cool app")
60+
assert.NoError(t, err)
61+
assert.Equal(t, "my-cool-app", appName)
62+
63+
// Leading/trailing spaces trimmed
64+
appName, err = getAppDirName(" my-app ")
65+
assert.NoError(t, err)
66+
assert.Equal(t, "my-app", appName)
5367
}
5468

5569
func TestGetAvailableDirectory(t *testing.T) {

0 commit comments

Comments
 (0)