diff --git a/cmd/project/create.go b/cmd/project/create.go index 549b3e0b..1adff451 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,11 +35,12 @@ 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 @@ -67,6 +71,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 +86,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 +133,31 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } + // --app must be an app ID when used with create + appFlagProvided := clients.Config.AppFlag != "" + if appFlagProvided && !types.IsAppID(clients.Config.AppFlag) { + return slackerror.New(slackerror.ErrInvalidAppFlag). + WithMessage("The --app flag requires an app ID when used with create") + } + + // --app requires --template + 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 +214,26 @@ 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, err := clients.Os.Getwd() + if err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + if err := os.Chdir(absProjectPath); err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + defer func() { + _ = os.Chdir(originalDir) + }() + if err := app.LinkExistingApp(ctx, clients, &types.App{}, false); err != nil { + return err + } + } + printCreateSuccess(ctx, clients, appDirPath) return nil } diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index d2fa65f1..e0c4b7b4 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,12 +16,17 @@ package project import ( "context" + "fmt" "testing" + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/app" "github.com/slackapi/slack-cli/internal/config" "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/slackdeps" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" @@ -853,3 +858,157 @@ 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) + }, + }, + "app flag with environment-style value returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "local"}, + 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 an app ID 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) + }, + }, + "app flag with lowercase id returns error": { + 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) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires an app ID 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 and links a deployed app": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocks(t, ctx, cm, cf) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "deployed"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetDeployed(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID) + assert.False(t, saved.IsDev) + }, + }, + "app flag without environment links a local app via prompt": { + 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) { + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocks(t, ctx, cm, cf) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID) + assert.True(t, saved.IsDev) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +var mockCreateLinkAuth = types.SlackAuth{ + Token: "xoxp-example", + TeamDomain: "team1", + TeamID: "T001", + EnterpriseID: "E001", + UserID: "U001", +} + +// setupCreateLinkMocks prepares the in-memory project config and manifest mocks +// needed by app.LinkExistingApp when called from the create command. +func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + projectDirPath := slackdeps.MockWorkingDirectory + cm.Os.On("Getwd").Return(projectDirPath, nil) + + if _, err := config.CreateProjectConfigDir(ctx, cm.Fs, projectDirPath); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to create the project config directory: %s", err)) + } + if _, err := config.CreateProjectHooksJSONFile(cm.Fs, projectDirPath, []byte("{}")); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to create the hooks file: %s", err)) + } + if err := config.SetManifestSource(ctx, cm.Fs, cm.Os, config.ManifestSourceRemote); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to set the manifest source: %s", err)) + } + + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything). + Return(types.SlackYaml{}, nil) + cf.AppClient().Manifest = manifestMock +}