Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
46 changes: 44 additions & 2 deletions cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,34 @@ 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"
"github.com/spf13/cobra"
)

// 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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minor: --app local will silently skip linking because it's not an App ID. While it's correct that we don't want to support --app local and --app deployed when creating an app, we should display an error immediately when it's not an App ID.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Commit c9f5bbf now displays an error during the creation if a non-App-ID is provided. For example, --app local or --app deployed.

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 {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minor: We should handle the error returned by clients.Os.Getwd() and handle the error / add a deferr to `os.Chdir(origianlDir).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Commit 7564232 now handles errors when getting or changing the working directory. It also defers restoring the original working directory, so that it's restored safely during errors.

if linkErr != nil {
return linkErr
}
}
Comment thread
zimeg marked this conversation as resolved.

printCreateSuccess(ctx, clients, appDirPath)
return nil
}
Expand Down
90 changes: 90 additions & 0 deletions cmd/project/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

note: We should be backing up the original LinkFunc and resetting it on each test. Right now the new func(...) will carry-over to future tests.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Commit 6533815 removes this code and now uses the real link function.

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)
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🧪 suggestion: Adding cases to cover the expected happy paths might be meaningful to guarantee the create command keeps support for these paths as underlined commands change

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If I understand correctly, Eden would prefer that we don't mock the LinkFunc (old pattern that's becoming an anti-pattern for us) and instead have our tests call into the real link code to verify that the app is correctly linked.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Commit 6533815 removes the LinkFunc stub and mock. It now uses app.LinkExistingApp(...) in the implementation and tests.

}
Loading