diff --git a/cmd/project/create.go b/cmd/project/create.go index 549b3e0b..dc501e65 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -18,13 +18,16 @@ import ( "context" "fmt" "math/rand" + "os" "path/filepath" "strings" "time" + "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -32,15 +35,17 @@ import ( ) // Flags -var createTemplateURLFlag string -var createGitBranchFlag string var createAppNameFlag string +var createEnvironmentFlag string +var createGitBranchFlag string var createListFlag bool var createSubdirFlag string +var createTemplateURLFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client var CreateFunc = create.Create +var LinkFunc = app.LinkExistingApp // promptObject describes the Github app template type promptObject struct { @@ -67,6 +72,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, {Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"}, {Command: "create --name my-project", Meaning: "Create a project named 'my-project'"}, {Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"}, + {Command: "create my-project -t slack-samples/bolt-js-starter-template --app A0123456789 --environment local", Meaning: "Create from template and link to an existing app"}, }), Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -81,6 +87,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)") cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates") cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory in the template to use as project") + cmd.Flags().StringVarP(&createEnvironmentFlag, "environment", "E", "", "environment to save existing app (local, deployed)") return cmd } @@ -127,6 +134,25 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } + // --app requires --template + appFlagProvided := clients.Config.AppFlag != "" && types.IsAppID(clients.Config.AppFlag) + if appFlagProvided && !templateFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --app flag requires the --template flag when used with create") + } + + // --environment requires --app and must be "local" or "deployed" + if cmd.Flags().Changed("environment") { + if !appFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --environment flag requires the --app flag when used with create") + } + if !types.IsAppFlagEnvironment(createEnvironmentFlag) { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --environment flag must be either 'local' or 'deployed'") + } + } + // Collect the template URL or select a starting template template, err := promptTemplateSelection(cmd, clients, categoryShortcut) if err != nil { @@ -183,6 +209,22 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] return err } + if appFlagProvided { + absProjectPath, err := filepath.Abs(appDirPath) + if err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + originalDir, _ := clients.Os.Getwd() + if err := os.Chdir(absProjectPath); err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + linkErr := LinkFunc(ctx, clients, &types.App{}, false) + _ = os.Chdir(originalDir) + if linkErr != nil { + return linkErr + } + } + printCreateSuccess(ctx, clients, appDirPath) return nil } diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index d2fa65f1..a3d4d332 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -22,6 +22,7 @@ import ( "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" @@ -853,3 +854,92 @@ func TestCreateCommand_confirmExternalTemplateSelection(t *testing.T) { }) } } + +func TestCreateCommand_AppFlag(t *testing.T) { + var createClientMock *CreateClientMock + + testutil.TableTestCommand(t, testutil.CommandTests{ + "app flag without template flag returns error": { + CmdArgs: []string{"my-app", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires the --template flag when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "environment flag without app flag returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--environment", "deployed"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --environment flag requires the --app flag when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "invalid environment flag returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "invalid"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --environment flag must be either 'local' or 'deployed'"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with template creates project then calls link": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + linkCalled := false + LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { + linkCalled = true + assert.False(t, shouldConfirm) + return nil + } + t.Cleanup(func() { + assert.True(t, linkCalled, "LinkFunc should have been called") + }) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag without environment still calls link": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + linkCalled := false + LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { + linkCalled = true + return nil + } + t.Cleanup(func() { + assert.True(t, linkCalled, "LinkFunc should have been called") + }) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +}