Skip to content

Commit 0d384e6

Browse files
srtaalejmwbrookszimeg
authored
feat: add --app flag support to slack create for linking existing apps (#565)
Co-authored-by: Michael Brooks <mbrooks@slack-corp.com> Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com>
1 parent 7563522 commit 0d384e6

2 files changed

Lines changed: 212 additions & 2 deletions

File tree

cmd/project/create.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,29 @@ import (
1818
"context"
1919
"fmt"
2020
"math/rand"
21+
"os"
2122
"path/filepath"
2223
"strings"
2324
"time"
2425

26+
"github.com/slackapi/slack-cli/cmd/app"
2527
"github.com/slackapi/slack-cli/internal/iostreams"
2628
"github.com/slackapi/slack-cli/internal/pkg/create"
2729
"github.com/slackapi/slack-cli/internal/shared"
30+
"github.com/slackapi/slack-cli/internal/shared/types"
2831
"github.com/slackapi/slack-cli/internal/slackerror"
2932
"github.com/slackapi/slack-cli/internal/slacktrace"
3033
"github.com/slackapi/slack-cli/internal/style"
3134
"github.com/spf13/cobra"
3235
)
3336

3437
// Flags
35-
var createTemplateURLFlag string
36-
var createGitBranchFlag string
3738
var createAppNameFlag string
39+
var createEnvironmentFlag string
40+
var createGitBranchFlag string
3841
var createListFlag bool
3942
var createSubdirFlag string
43+
var createTemplateURLFlag string
4044

4145
// Handle to client's create function used for testing
4246
// 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.`,
6771
{Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"},
6872
{Command: "create --name my-project", Meaning: "Create a project named 'my-project'"},
6973
{Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"},
74+
{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"},
7075
}),
7176
Args: cobra.MaximumNArgs(2),
7277
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.`,
8186
cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)")
8287
cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates")
8388
cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory in the template to use as project")
89+
cmd.Flags().StringVarP(&createEnvironmentFlag, "environment", "E", "", "environment to save existing app (local, deployed)")
8490

8591
return cmd
8692
}
@@ -127,6 +133,31 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
127133
WithMessage("The --subdir flag requires the --template flag")
128134
}
129135

136+
// --app must be an app ID when used with create
137+
appFlagProvided := clients.Config.AppFlag != ""
138+
if appFlagProvided && !types.IsAppID(clients.Config.AppFlag) {
139+
return slackerror.New(slackerror.ErrInvalidAppFlag).
140+
WithMessage("The --app flag requires an app ID when used with create")
141+
}
142+
143+
// --app requires --template
144+
if appFlagProvided && !templateFlagProvided {
145+
return slackerror.New(slackerror.ErrMismatchedFlags).
146+
WithMessage("The --app flag requires the --template flag when used with create")
147+
}
148+
149+
// --environment requires --app and must be "local" or "deployed"
150+
if cmd.Flags().Changed("environment") {
151+
if !appFlagProvided {
152+
return slackerror.New(slackerror.ErrMismatchedFlags).
153+
WithMessage("The --environment flag requires the --app flag when used with create")
154+
}
155+
if !types.IsAppFlagEnvironment(createEnvironmentFlag) {
156+
return slackerror.New(slackerror.ErrMismatchedFlags).
157+
WithMessage("The --environment flag must be either 'local' or 'deployed'")
158+
}
159+
}
160+
130161
// Collect the template URL or select a starting template
131162
template, err := promptTemplateSelection(cmd, clients, categoryShortcut)
132163
if err != nil {
@@ -183,6 +214,26 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
183214
return err
184215
}
185216

217+
if appFlagProvided {
218+
absProjectPath, err := filepath.Abs(appDirPath)
219+
if err != nil {
220+
return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
221+
}
222+
originalDir, err := clients.Os.Getwd()
223+
if err != nil {
224+
return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
225+
}
226+
if err := os.Chdir(absProjectPath); err != nil {
227+
return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess)
228+
}
229+
defer func() {
230+
_ = os.Chdir(originalDir)
231+
}()
232+
if err := app.LinkExistingApp(ctx, clients, &types.App{}, false); err != nil {
233+
return err
234+
}
235+
}
236+
186237
printCreateSuccess(ctx, clients, appDirPath)
187238
return nil
188239
}

cmd/project/create_test.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,17 @@ package project
1616

1717
import (
1818
"context"
19+
"fmt"
1920
"testing"
2021

22+
"github.com/slackapi/slack-cli/internal/api"
23+
"github.com/slackapi/slack-cli/internal/app"
2124
"github.com/slackapi/slack-cli/internal/config"
2225
"github.com/slackapi/slack-cli/internal/iostreams"
2326
"github.com/slackapi/slack-cli/internal/pkg/create"
2427
"github.com/slackapi/slack-cli/internal/shared"
28+
"github.com/slackapi/slack-cli/internal/shared/types"
29+
"github.com/slackapi/slack-cli/internal/slackdeps"
2530
"github.com/slackapi/slack-cli/internal/slackerror"
2631
"github.com/slackapi/slack-cli/test/testutil"
2732
"github.com/spf13/cobra"
@@ -853,3 +858,157 @@ func TestCreateCommand_confirmExternalTemplateSelection(t *testing.T) {
853858
})
854859
}
855860
}
861+
862+
func TestCreateCommand_AppFlag(t *testing.T) {
863+
var createClientMock *CreateClientMock
864+
865+
testutil.TableTestCommand(t, testutil.CommandTests{
866+
"app flag without template flag returns error": {
867+
CmdArgs: []string{"my-app", "--app", "A0123456789"},
868+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
869+
createClientMock = new(CreateClientMock)
870+
CreateFunc = createClientMock.Create
871+
},
872+
ExpectedErrorStrings: []string{"The --app flag requires the --template flag when used with create"},
873+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
874+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
875+
},
876+
},
877+
"app flag with environment-style value returns error": {
878+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "local"},
879+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
880+
createClientMock = new(CreateClientMock)
881+
CreateFunc = createClientMock.Create
882+
},
883+
ExpectedErrorStrings: []string{"The --app flag requires an app ID when used with create"},
884+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
885+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
886+
},
887+
},
888+
"app flag with lowercase id returns error": {
889+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "a0123456789"},
890+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
891+
createClientMock = new(CreateClientMock)
892+
CreateFunc = createClientMock.Create
893+
},
894+
ExpectedErrorStrings: []string{"The --app flag requires an app ID when used with create"},
895+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
896+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
897+
},
898+
},
899+
"environment flag without app flag returns error": {
900+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--environment", "deployed"},
901+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
902+
createClientMock = new(CreateClientMock)
903+
CreateFunc = createClientMock.Create
904+
},
905+
ExpectedErrorStrings: []string{"The --environment flag requires the --app flag when used with create"},
906+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
907+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
908+
},
909+
},
910+
"invalid environment flag returns error": {
911+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "invalid"},
912+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
913+
createClientMock = new(CreateClientMock)
914+
CreateFunc = createClientMock.Create
915+
},
916+
ExpectedErrorStrings: []string{"The --environment flag must be either 'local' or 'deployed'"},
917+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
918+
createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
919+
},
920+
},
921+
"app flag with template creates project and links a deployed app": {
922+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"},
923+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
924+
createClientMock = new(CreateClientMock)
925+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil)
926+
CreateFunc = createClientMock.Create
927+
928+
cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil)
929+
cm.AddDefaultMocks()
930+
setupCreateLinkMocks(t, ctx, cm, cf)
931+
cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything).
932+
Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe()
933+
cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything).
934+
Return(iostreams.SelectPromptResponse{Flag: true, Option: mockCreateLinkAuth.TeamDomain}, nil)
935+
cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything).
936+
Return("A0123456789", nil)
937+
cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything).
938+
Return(iostreams.SelectPromptResponse{Flag: true, Option: "deployed"}, nil)
939+
cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID).
940+
Return(api.GetAppStatusResult{}, nil)
941+
},
942+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
943+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
944+
saved, err := cm.AppClient.GetDeployed(ctx, mockCreateLinkAuth.TeamID)
945+
require.NoError(t, err)
946+
assert.Equal(t, "A0123456789", saved.AppID)
947+
assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID)
948+
assert.False(t, saved.IsDev)
949+
},
950+
},
951+
"app flag without environment links a local app via prompt": {
952+
CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"},
953+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
954+
createClientMock = new(CreateClientMock)
955+
createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil)
956+
CreateFunc = createClientMock.Create
957+
958+
cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil)
959+
cm.AddDefaultMocks()
960+
setupCreateLinkMocks(t, ctx, cm, cf)
961+
cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything).
962+
Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe()
963+
cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything).
964+
Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil)
965+
cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything).
966+
Return("A0123456789", nil)
967+
cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything).
968+
Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil)
969+
cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID).
970+
Return(api.GetAppStatusResult{}, nil)
971+
},
972+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
973+
createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything)
974+
saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID)
975+
require.NoError(t, err)
976+
assert.Equal(t, "A0123456789", saved.AppID)
977+
assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID)
978+
assert.True(t, saved.IsDev)
979+
},
980+
},
981+
}, func(cf *shared.ClientFactory) *cobra.Command {
982+
return NewCreateCommand(cf)
983+
})
984+
}
985+
986+
var mockCreateLinkAuth = types.SlackAuth{
987+
Token: "xoxp-example",
988+
TeamDomain: "team1",
989+
TeamID: "T001",
990+
EnterpriseID: "E001",
991+
UserID: "U001",
992+
}
993+
994+
// setupCreateLinkMocks prepares the in-memory project config and manifest mocks
995+
// needed by app.LinkExistingApp when called from the create command.
996+
func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
997+
projectDirPath := slackdeps.MockWorkingDirectory
998+
cm.Os.On("Getwd").Return(projectDirPath, nil)
999+
1000+
if _, err := config.CreateProjectConfigDir(ctx, cm.Fs, projectDirPath); err != nil {
1001+
require.FailNow(t, fmt.Sprintf("Failed to create the project config directory: %s", err))
1002+
}
1003+
if _, err := config.CreateProjectHooksJSONFile(cm.Fs, projectDirPath, []byte("{}")); err != nil {
1004+
require.FailNow(t, fmt.Sprintf("Failed to create the hooks file: %s", err))
1005+
}
1006+
if err := config.SetManifestSource(ctx, cm.Fs, cm.Os, config.ManifestSourceRemote); err != nil {
1007+
require.FailNow(t, fmt.Sprintf("Failed to set the manifest source: %s", err))
1008+
}
1009+
1010+
manifestMock := &app.ManifestMockObject{}
1011+
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
1012+
Return(types.SlackYaml{}, nil)
1013+
cf.AppClient().Manifest = manifestMock
1014+
}

0 commit comments

Comments
 (0)