diff --git a/cmd/project/create.go b/cmd/project/create.go index 18f66d1d..17f77435 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -25,6 +25,7 @@ import ( "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/manifest" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -229,10 +230,34 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] defer func() { _ = os.Chdir(originalDir) }() + linkedApp := &types.App{} if err := app.LinkExistingApp(ctx, clients, linkedApp); err != nil { return err } + + if linkedApp.AppID != "" { + auth, err := clients.Auth().AuthWithTeamID(ctx, linkedApp.TeamID) + if err != nil { + return err + } + fetchErr := manifest.SetManifestLocal(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", + }, + })) + } else { + clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ + Emoji: "pencil2", + Text: "Manifest", + Secondary: []string{"Written to manifest.json from remote app settings"}, + })) + } + } clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ Emoji: "house", Text: "App", diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index e0c4b7b4..e613b385 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -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,98 @@ 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.Auth.On("AuthWithTeamID", mock.Anything, mock.Anything).Return(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 := &app.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 := &app.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,11 +1084,10 @@ 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) + cm.Auth.On("AuthWithTeamID", mock.Anything, mock.Anything).Return(mockCreateLinkAuth, 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)) @@ -1010,5 +1102,7 @@ func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsM manifestMock := &app.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/internal/manifest/export.go b/internal/manifest/export.go new file mode 100644 index 00000000..6b1c53c1 --- /dev/null +++ b/internal/manifest/export.go @@ -0,0 +1,45 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "context" + "path/filepath" + + "github.com/slackapi/slack-cli/internal/goutils" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/afero" +) + +// SetManifestLocal fetches the app manifest from remote settings and writes it to the project. +func SetManifestLocal(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 + } + manifestJSON, err := goutils.JSONMarshalUnescapedIndent(slackYaml.AppManifest) + if err != nil { + return err + } + manifestPath := filepath.Join(projectPath, "manifest.json") + if err := afero.WriteFile(clients.Fs, manifestPath, []byte(manifestJSON), 0644); err != nil { + return err + } + hash, err := clients.Config.ProjectConfig.Cache().NewManifestHash(ctx, slackYaml.AppManifest) + if err != nil { + return err + } + return clients.Config.ProjectConfig.Cache().SetManifestHash(ctx, appID, hash) +} diff --git a/internal/manifest/export_test.go b/internal/manifest/export_test.go new file mode 100644 index 00000000..1ea48e43 --- /dev/null +++ b/internal/manifest/export_test.go @@ -0,0 +1,139 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/cache" + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/goutils" + "github.com/slackapi/slack-cli/internal/hooks" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_SetManifestLocal(t *testing.T) { + testManifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Test App"}, + }, + } + + tests := map[string]struct { + setupMocks func(*shared.ClientsMock, *shared.ClientFactory) + expectError bool + assertFile bool + }{ + "writes manifest and saves hash on success": { + setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) { + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-token", "A001"). + Return(testManifest, nil) + clients.AppClient().Manifest = manifestMock + + cc := cache.NewCacheMock() + cc.On("NewManifestHash", mock.Anything, testManifest.AppManifest). + Return(cache.Hash("abc123"), nil) + cc.On("SetManifestHash", mock.Anything, "A001", cache.Hash("abc123")). + Return(nil) + + proj := config.NewProjectConfigMock() + proj.On("Cache").Return(cc) + clients.Config.ProjectConfig = proj + }, + expectError: false, + assertFile: true, + }, + "returns error when GetManifestRemote fails": { + setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) { + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything). + Return(types.SlackYaml{}, slackerror.New("api error")) + clients.AppClient().Manifest = manifestMock + }, + expectError: true, + assertFile: false, + }, + "returns error when file write fails": { + setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) { + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything). + Return(testManifest, nil) + clients.AppClient().Manifest = manifestMock + + clients.Fs = afero.NewReadOnlyFs(&afero.MemMapFs{}) + }, + expectError: true, + assertFile: false, + }, + "returns error when SetManifestHash fails": { + setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) { + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything). + Return(testManifest, nil) + clients.AppClient().Manifest = manifestMock + + cc := cache.NewCacheMock() + cc.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("abc123"), nil) + cc.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(slackerror.New("cache write error")) + + proj := config.NewProjectConfigMock() + proj.On("Cache").Return(cc) + clients.Config.ProjectConfig = proj + }, + expectError: true, + assertFile: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(c *shared.ClientFactory) { + c.SDKConfig = hooks.NewSDKConfigMock() + }) + + tc.setupMocks(clientsMock, clients) + + err := SetManifestLocal(ctx, clients, "xoxp-token", "A001", "/project") + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + if tc.assertFile { + content, readErr := afero.ReadFile(clients.Fs, "/project/manifest.json") + require.NoError(t, readErr) + + expected, _ := goutils.JSONMarshalUnescapedIndent(testManifest.AppManifest) + assert.Equal(t, expected, string(content)) + } + }) + } +} diff --git a/internal/style/style.go b/internal/style/style.go index 20956be1..74d6e67f 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -118,6 +118,8 @@ func Emoji(alias string) string { padding = " " case "beach_with_umbrella": padding = " " + case "pencil2": + padding = " " } return emoji.Sprint(":"+alias+":") + padding