diff --git a/cmd/app/link.go b/cmd/app/link.go index 044f56c3..6ca77347 100644 --- a/cmd/app/link.go +++ b/cmd/app/link.go @@ -97,7 +97,7 @@ func LinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, app *ty // Add empty line between executed command and first output clients.IO.PrintInfo(ctx, false, "") - err = LinkExistingApp(ctx, clients, app, false) + _, err = LinkExistingApp(ctx, clients, app, false) if err != nil { return err } @@ -130,7 +130,7 @@ func LinkAppHeaderSection(ctx context.Context, clients *shared.ClientFactory, sh // When shouldConfirm is true, a confirmation prompt will ask the user if they want to // link an existing app and additional information is included in the header. // The shouldConfirm option is encouraged for third-party callers. -func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *types.App, shouldConfirm bool) (err error) { +func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *types.App, shouldConfirm bool) (_ *types.SlackAuth, err error) { // Header section LinkAppHeaderSection(ctx, clients, shouldConfirm) @@ -139,21 +139,21 @@ func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *ty proceed, err := clients.IO.ConfirmPrompt(ctx, LinkAppConfirmPromptText, true) if err != nil { clients.IO.PrintDebug(ctx, "Error prompting to add an existing app: %s", err) - return err + return nil, err } // Add newline to match the trailing newline inserted from the footer section clients.IO.PrintInfo(ctx, false, "") if !proceed { - return nil + return nil, nil } } // App Manifest section manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) if err != nil { - return err + return nil, err } configPath := filepath.Join(config.ProjectConfigDirName, config.ProjectConfigJSONFilename) @@ -170,26 +170,26 @@ func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *ty var auth *types.SlackAuth *app, auth, err = promptExistingApp(ctx, clients) if err != nil { - return err + return nil, err } appIDs := []string{app.AppID} _, err = clients.API().GetAppStatus(ctx, auth.Token, appIDs, app.TeamID) if err != nil { - return err + return nil, err } // Save the app to the project err = saveAppToJSON(ctx, clients, *app) if err != nil { clients.IO.PrintDebug(ctx, "Error saving app to file when linking existing app: %s", err) - return err + return nil, err } // Footer section LinkAppFooterSection(ctx, clients, app) - return nil + return auth, nil } // LinkAppFooterSection displays the details of app that was added to the project. diff --git a/cmd/project/create.go b/cmd/project/create.go index 1adff451..148fdb5d 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -16,6 +16,7 @@ package project import ( "context" + "encoding/json" "fmt" "math/rand" "os" @@ -31,6 +32,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -229,8 +231,24 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] defer func() { _ = os.Chdir(originalDir) }() - if err := app.LinkExistingApp(ctx, clients, &types.App{}, false); err != nil { - return err + + linkedApp := &types.App{} + auth, linkErr := app.LinkExistingApp(ctx, clients, linkedApp, false) + if linkErr != nil { + return linkErr + } + + if auth != nil && linkedApp.AppID != "" { + fetchErr := fetchAndWriteRemoteManifest(ctx, clients, auth.Token, linkedApp.AppID, absProjectPath) + if fetchErr != nil { + clients.IO.PrintWarning(ctx, "%s", style.Sectionf(style.TextSection{ + Text: "Could not fetch the remote app manifest", + Secondary: []string{ + fetchErr.Error(), + "The template manifest was kept unchanged", + }, + })) + } } } @@ -289,6 +307,21 @@ func printCreateSuccess(ctx context.Context, clients *shared.ClientFactory, appP clients.IO.PrintTrace(ctx, slacktrace.CreateSuccess) } +// fetchAndWriteRemoteManifest fetches the app manifest from remote settings and writes it to the project. +func fetchAndWriteRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token, appID, projectPath string) error { + slackYaml, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) + if err != nil { + return err + } + data, err := json.MarshalIndent(slackYaml.AppManifest, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + manifestPath := filepath.Join(projectPath, "manifest.json") + return afero.WriteFile(clients.Fs, manifestPath, data, 0644) +} + // generateRandomAppName will create a random app name based on two words and a number func generateRandomAppName() string { rand.New(rand.NewSource(time.Now().UnixNano())) diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index e0c4b7b4..c6f6cbe4 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -20,7 +20,7 @@ import ( "testing" "github.com/slackapi/slack-cli/internal/api" - "github.com/slackapi/slack-cli/internal/app" + internalApp "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" @@ -29,6 +29,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackdeps" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -983,6 +984,97 @@ func TestCreateCommand_AppFlag(t *testing.T) { }) } +func TestCreateCommand_AppFlag_FetchesRemoteManifest(t *testing.T) { + var createClientMock *CreateClientMock + + mockAuth := types.SlackAuth{ + Token: "xoxp-test-token", + TeamDomain: "test-team", + TeamID: "T001", + UserID: "U001", + } + mockManifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "My Remote App", + Description: "An app from remote settings", + }, + }, + } + + setupAppFlagMocks := func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) string { + projectDir := t.TempDir() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + + cm.Os.On("Getwd").Return(projectDir, nil) + + err := cm.Fs.MkdirAll(projectDir+"/.slack", 0755) + require.NoError(t, err) + err = afero.WriteFile(cm.Fs, projectDir+"/.slack/hooks.json", []byte("{}"), 0644) + require.NoError(t, err) + + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil) + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockAuth}, nil) + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 0, Option: mockAuth.TeamDomain}, 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, mockAuth.Token, []string{"A0123456789"}, mockAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + + return projectDir + } + + var projectDir string + + testutil.TableTestCommand(t, testutil.CommandTests{ + "fetches remote manifest after linking app": { + 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) { + projectDir = setupAppFlagMocks(t, ctx, cm, cf) + + manifestMock := &internalApp.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mockAuth.Token, "A0123456789"). + Return(mockManifest, nil) + cf.AppClient().Manifest = manifestMock + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + + manifestData, err := afero.ReadFile(cm.Fs, projectDir+"/manifest.json") + require.NoError(t, err) + assert.Contains(t, string(manifestData), `"name": "My Remote App"`) + assert.Contains(t, string(manifestData), `"description": "An app from remote settings"`) + }, + }, + "warns on manifest fetch failure": { + 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) { + projectDir = setupAppFlagMocks(t, ctx, cm, cf) + + manifestMock := &internalApp.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mockAuth.Token, "A0123456789"). + Return(types.SlackYaml{}, slackerror.New("network error")) + cf.AppClient().Manifest = manifestMock + }, + ExpectedStdoutOutputs: []string{ + "Could not fetch the remote app manifest", + "The template manifest was kept unchanged", + }, + 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) + }) +} + var mockCreateLinkAuth = types.SlackAuth{ Token: "xoxp-example", TeamDomain: "team1", @@ -991,8 +1083,6 @@ var mockCreateLinkAuth = types.SlackAuth{ 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) @@ -1007,8 +1097,10 @@ func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsM require.FailNow(t, fmt.Sprintf("Failed to set the manifest source: %s", err)) } - manifestMock := &app.ManifestMockObject{} + manifestMock := &internalApp.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything). Return(types.SlackYaml{}, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything). + Return(types.SlackYaml{}, nil) cf.AppClient().Manifest = manifestMock } diff --git a/cmd/project/init.go b/cmd/project/init.go index 9f9d5f90..dff035ff 100644 --- a/cmd/project/init.go +++ b/cmd/project/init.go @@ -110,7 +110,7 @@ func projectInitCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, a _ = create.InstallProjectDependencies(ctx, clients, projectDirPath) // Add an existing app to the project - err = app.LinkExistingApp(ctx, clients, &types.App{}, true) + _, err = app.LinkExistingApp(ctx, clients, &types.App{}, true) if err != nil { // Display the error but continue to init clients.IO.PrintError(ctx, "%s", err.Error())