diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index 3b9df9ac..869ebcdb 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -8,6 +8,7 @@ The following is a list of currently available experiments. We'll remove experim * `bolt-install`: enables creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). * `slack create` and `slack init` now set manifest source to "app settings" (remote) for Bolt JS & Bolt Python projects ([PR#96](https://github.com/slackapi/slack-cli/pull/96)). + * `slack run` supports creating and installing Bolt Framework apps that have the manifest source set to "app settings (remote)" ([PR#111](https://github.com/slackapi/slack-cli/pull/111)). * `read-only-collaborators`: enables creating and modifying collaborator permissions via the `slack collaborator` commands. ## Experiments changelog diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index b3040708..273ebef7 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -340,8 +340,11 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran if err != nil { return types.App{}, api.DeveloperAppInstallResult{}, "", err } - if !manifestUpdates && !manifestCreates { - return app, api.DeveloperAppInstallResult{}, "", nil + + if !clients.Config.WithExperimentOn(experiment.BoltInstall) { + if !manifestUpdates && !manifestCreates { + return app, api.DeveloperAppInstallResult{}, "", nil + } } apiInterface := clients.API() @@ -365,17 +368,42 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran clients.EventTracker.SetAuthEnterpriseID(*authSession.EnterpriseID) } - slackYaml, err := clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) - if err != nil { - return app, api.DeveloperAppInstallResult{}, "", err + // When the BoltInstall experiment is enabled, we need to get the manifest from the local file + // if the manifest source is local or if we are creating a new app. After an app is created, + // app settings becomes the source of truth for remote manifests, so updates and installs always + // get the latest manifest from app settings. + // When the BoltInstall experiment is disabled, we get the manifest from the local file because + // this is how the original implementation worked. + var slackManifest types.SlackYaml + if clients.Config.WithExperimentOn(experiment.BoltInstall) { + manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) + if err != nil { + return app, api.DeveloperAppInstallResult{}, "", err + } + if manifestSource.Equals(config.ManifestSourceLocal) || manifestCreates { + slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return app, api.DeveloperAppInstallResult{}, "", err + } + } else { + slackManifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, auth.Token, app.AppID) + if err != nil { + return app, api.DeveloperAppInstallResult{}, "", err + } + } + } else { + slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return app, api.DeveloperAppInstallResult{}, "", err + } } - log.Data["appName"] = slackYaml.DisplayInformation.Name + log.Data["appName"] = slackManifest.DisplayInformation.Name log.Data["isUpdate"] = app.AppID != "" log.Data["teamName"] = *authSession.TeamName log.Log("INFO", "app_install_manifest") - manifest := slackYaml.AppManifest + manifest := slackManifest.AppManifest appendLocalToDisplayName(&manifest) if manifest.IsFunctionRuntimeSlackHosted() { configureLocalManifest(ctx, clients, &manifest) @@ -468,6 +496,7 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran log.Info("app_install_start") var installState types.InstallState result, installState, err := apiInterface.DeveloperAppInstall(ctx, clients.IO, token, app, botScopes, outgoingDomains, orgGrantWorkspaceID, clients.Config.AutoRequestAAAFlag) + if err != nil { err = slackerror.Wrap(err, slackerror.ErrAppInstall) return app, api.DeveloperAppInstallResult{}, "", err @@ -483,7 +512,7 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran } // - // TODO(@mbrevoort) - Currently, cannot update the icon if app is not hosted + // TODO: Currently, cannot update the icon if app is not hosted. // // upload icon, default to icon.png // var iconPath = slackYaml.Icon @@ -644,6 +673,12 @@ func shouldCreateManifest(ctx context.Context, clients *shared.ClientFactory, ap if !clients.Config.WithExperimentOn(experiment.BoltFrameworks) { return app.AppID == "", nil } + + // When the BoltInstall experiment is enabled, apps can always be created with any manifest source. + if clients.Config.WithExperimentOn(experiment.BoltInstall) { + return app.AppID == "", nil + } + manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) if err != nil { return false, err diff --git a/internal/pkg/apps/install_test.go b/internal/pkg/apps/install_test.go index 68a97dad..2acc46e2 100644 --- a/internal/pkg/apps/install_test.go +++ b/internal/pkg/apps/install_test.go @@ -640,34 +640,33 @@ func TestInstallLocalApp(t *testing.T) { mockUserID := "U001" tests := map[string]struct { - isExperimental bool - mockApp types.App - mockAPICreate api.CreateAppResult - mockAPICreateError error - mockAPIInstall api.DeveloperAppInstallResult - mockAPIInstallState types.InstallState - mockAPIInstallError error - mockAPIUpdate api.UpdateAppResult - mockAPIUpdateError error - mockAuth types.SlackAuth - mockAuthSession api.AuthSession - mockBoltExperiment bool - mockConfirmPrompt bool - mockIsTTY bool - mockManifest types.SlackYaml - mockManifestHashInitial cache.Hash - mockManifestHashUpdated cache.Hash - mockManifestSource config.ManifestSource - mockOrgGrantWorkspaceID string - expectedApp types.App - expectedCreate bool - expectedInstallState types.InstallState - expectedManifest types.AppManifest - expectedUpdate bool + mockApp types.App + mockAPICreate api.CreateAppResult + mockAPICreateError error + mockAPIInstall api.DeveloperAppInstallResult + mockAPIInstallState types.InstallState + mockAPIInstallError error + mockAPIUpdate api.UpdateAppResult + mockAPIUpdateError error + mockAuth types.SlackAuth + mockAuthSession api.AuthSession + mockBoltExperiment bool + mockBoltInstallExperiment bool + mockConfirmPrompt bool + mockIsTTY bool + mockManifest types.SlackYaml + mockManifestHashInitial cache.Hash + mockManifestHashUpdated cache.Hash + mockManifestSource config.ManifestSource + mockOrgGrantWorkspaceID string + expectedApp types.App + expectedCreate bool + expectedInstallState types.InstallState + expectedManifest types.AppManifest + expectedUpdate bool }{ - "create a new run on slack app with a local function runtime using expected rosi defaults": { - isExperimental: false, - mockApp: types.App{}, + "create and install a new ROSI app with a local function runtime using expected rosi defaults when the BoltInstall experiment is disabled": { + mockApp: types.App{}, mockAPICreate: api.CreateAppResult{ AppID: "A001", }, @@ -698,6 +697,7 @@ func TestInstallLocalApp(t *testing.T) { }, }, }, + mockAPIInstallState: types.InstallSuccess, expectedApp: types.App{ AppID: "A001", EnterpriseID: mockEnterpriseID, @@ -722,10 +722,11 @@ func TestInstallLocalApp(t *testing.T) { EventSubscriptions: &types.ManifestEventSubscriptions{}, }, }, - expectedCreate: true, + expectedCreate: true, + expectedInstallState: types.InstallSuccess, + expectedUpdate: false, }, - "update an existing local bolt app with a remote function runtime without manifest changes": { - isExperimental: true, + "update and install an existing local bolt app with a remote function runtime without manifest changes when the BoltInstall experiment is disabled": { mockApp: types.App{ AppID: "A002", TeamID: mockTeamID, @@ -770,6 +771,7 @@ func TestInstallLocalApp(t *testing.T) { }, mockManifestHashInitial: cache.Hash("123"), mockManifestHashUpdated: cache.Hash("789"), + mockAPIInstallState: types.InstallSuccess, expectedApp: types.App{ AppID: "A002", IsDev: true, @@ -795,10 +797,11 @@ func TestInstallLocalApp(t *testing.T) { }, }, }, - expectedUpdate: true, + expectedCreate: false, + expectedInstallState: types.InstallSuccess, + expectedUpdate: true, }, - "update an existing local bolt app without a function runtime without manifest changes": { - isExperimental: true, + "update and install an existing local bolt app without a function runtime without manifest changes when the BoltInstall experiment is disabled": { mockApp: types.App{ AppID: "A003", TeamID: mockTeamID, @@ -820,9 +823,10 @@ func TestInstallLocalApp(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockBoltExperiment: true, - mockConfirmPrompt: true, - mockIsTTY: true, + mockBoltExperiment: true, + mockAPIInstallState: types.InstallSuccess, + mockConfirmPrompt: true, + mockIsTTY: true, mockManifest: types.SlackYaml{ AppManifest: types.AppManifest{ DisplayInformation: types.DisplayInformation{ @@ -859,9 +863,11 @@ func TestInstallLocalApp(t *testing.T) { SocketModeEnabled: &mockTrue, }, }, - expectedUpdate: true, + expectedCreate: false, + expectedInstallState: types.InstallSuccess, + expectedUpdate: true, }, - "avoids updating or installing an app with a remote manifest": { + "skip updating and skip installing an app with a remote manifest when the BoltInstall experiment is disabled": { mockApp: types.App{ AppID: "A004", IsDev: true, @@ -894,6 +900,380 @@ func TestInstallLocalApp(t *testing.T) { expectedInstallState: "", expectedUpdate: false, }, + "create and install a new ROSI app when manifest is local and BoltInstall experiment is enabled": { + mockApp: types.App{}, + mockAPICreate: api.CreateAppResult{ + AppID: "A001", + }, + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockAuth: types.SlackAuth{ + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + EnterpriseID: &mockEnterpriseID, + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + DisplayInformation: types.DisplayInformation{ + Name: "example-1", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.SlackHosted, + }, + }, + }, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockManifestSource: config.ManifestSourceLocal, + mockAPIInstallState: types.InstallSuccess, + expectedApp: types.App{ + AppID: "A001", + EnterpriseID: mockEnterpriseID, + IsDev: true, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + UserID: mockUserID, + }, + expectedManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + DisplayInformation: types.DisplayInformation{ + Name: "example-1 (local)", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.LocallyRun, + SocketModeEnabled: &mockTrue, + Interactivity: &types.ManifestInteractivity{ + IsEnabled: true, + }, + EventSubscriptions: &types.ManifestEventSubscriptions{}, + }, + }, + expectedCreate: true, + expectedInstallState: types.InstallSuccess, + expectedUpdate: false, + }, + "update and install an existing ROSI app when manifest is local and BoltInstall experiment is enabled": { + mockApp: types.App{ + AppID: "A002", + TeamID: mockTeamID, + UserID: mockUserID, + }, + mockAPICreateError: slackerror.New(slackerror.ErrAppCreate), + mockAPIUpdate: api.UpdateAppResult{ + AppID: "A002", + }, + mockAuth: types.SlackAuth{ + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + DisplayInformation: types.DisplayInformation{ + Name: "example-2", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.SlackHosted, + }, + }, + }, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockManifestSource: config.ManifestSourceLocal, + mockManifestHashInitial: cache.Hash("123"), + mockManifestHashUpdated: cache.Hash("789"), + mockAPIInstallState: types.InstallSuccess, + expectedApp: types.App{ + AppID: "A002", + IsDev: true, + TeamID: mockTeamID, + UserID: mockUserID, + }, + expectedManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + DisplayInformation: types.DisplayInformation{ + Name: "example-2 (local)", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.LocallyRun, + SocketModeEnabled: &mockTrue, + Interactivity: &types.ManifestInteractivity{ + IsEnabled: true, + }, + EventSubscriptions: &types.ManifestEventSubscriptions{}, + }, + }, + expectedCreate: false, + expectedInstallState: types.InstallSuccess, + expectedUpdate: true, + }, + "create and install a new bolt app when manifest is local and BoltInstall experiment is enabled": { + mockApp: types.App{}, + mockAPICreate: api.CreateAppResult{ + AppID: "A001", + }, + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockAuth: types.SlackAuth{ + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + EnterpriseID: &mockEnterpriseID, + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + }, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockAPIInstallState: types.InstallSuccess, + mockManifestSource: config.ManifestSourceLocal, + expectedApp: types.App{ + AppID: "A001", + EnterpriseID: mockEnterpriseID, + IsDev: true, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + UserID: mockUserID, + }, + expectedManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3 (local)", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3 (local)", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + expectedCreate: true, + expectedInstallState: types.InstallSuccess, + expectedUpdate: false, + }, + "update and install an existing bolt app with a local manifest when the BoltInstall experiment is enabled": { + mockApp: types.App{ + AppID: "A004", + IsDev: true, + TeamID: mockTeamID, + UserID: mockUserID, + }, + mockAuth: types.SlackAuth{ + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + }, + mockAPICreateError: slackerror.New(slackerror.ErrAppCreate), + mockAPIUpdate: api.UpdateAppResult{ + AppID: "A004", + }, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockAPIInstallState: types.InstallSuccess, + mockManifestSource: config.ManifestSourceLocal, + mockManifestHashInitial: cache.Hash("123"), + mockManifestHashUpdated: cache.Hash("789"), + mockConfirmPrompt: true, + mockIsTTY: true, + expectedApp: types.App{ + AppID: "A004", + IsDev: true, + TeamID: mockTeamID, + UserID: mockUserID, + }, + expectedManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3 (local)", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3 (local)", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + expectedCreate: false, + expectedInstallState: types.InstallSuccess, + expectedUpdate: true, + }, + "create and install a new bolt app when manifest is remote and BoltInstall experiment is enabled": { + mockApp: types.App{}, + mockAPICreate: api.CreateAppResult{ + AppID: "A001", + }, + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockAuth: types.SlackAuth{ + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + EnterpriseID: &mockEnterpriseID, + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + }, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockManifestSource: config.ManifestSourceRemote, + mockAPIInstallState: types.InstallSuccess, + expectedApp: types.App{ + AppID: "A001", + EnterpriseID: mockEnterpriseID, + IsDev: true, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + UserID: mockUserID, + }, + expectedManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3 (local)", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3 (local)", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + expectedCreate: true, + expectedInstallState: types.InstallSuccess, + expectedUpdate: false, + }, + "skip updating and allow installing an existing bolt app when manifest is remote and BoltInstall experiment is enabled": { + mockApp: types.App{ + AppID: "A004", + IsDev: true, + TeamID: mockTeamID, + UserID: mockUserID, + }, + mockAuth: types.SlackAuth{ + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "example-3", + }, + Features: &types.AppFeatures{ + BotUser: types.BotUser{ + DisplayName: "example-3", + }, + }, + Settings: &types.AppSettings{ + SocketModeEnabled: &mockTrue, + }, + }, + }, + mockAPICreateError: slackerror.New(slackerror.ErrAppCreate), + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockAPIInstallState: types.InstallSuccess, + mockBoltExperiment: true, + mockBoltInstallExperiment: true, + mockManifestSource: config.ManifestSourceRemote, + expectedApp: types.App{ + AppID: "A004", + IsDev: true, + TeamID: mockTeamID, + UserID: mockUserID, + }, + expectedCreate: false, + expectedInstallState: types.InstallSuccess, + expectedUpdate: false, + }, } for name, tt := range tests { @@ -979,6 +1359,7 @@ func TestInstallLocalApp(t *testing.T) { } manifestMock := &app.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockManifest, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockManifest, nil) clientsMock.AppClient.Manifest = manifestMock mockProjectConfig := config.NewProjectConfigMock() if tt.mockBoltExperiment { @@ -986,6 +1367,10 @@ func TestInstallLocalApp(t *testing.T) { clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug) mockProjectConfig.On("GetManifestSource", mock.Anything).Return(tt.mockManifestSource, nil) } + if tt.mockBoltInstallExperiment { + clientsMock.Config.ExperimentsFlag = append(clientsMock.Config.ExperimentsFlag, string(experiment.BoltInstall)) + clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug) + } mockProjectCache := cache.NewCacheMock() mockProjectCache.On( "GetManifestHash", diff --git a/internal/prompts/app_select.go b/internal/prompts/app_select.go index 86717639..a5a26a82 100644 --- a/internal/prompts/app_select.go +++ b/internal/prompts/app_select.go @@ -1162,7 +1162,9 @@ func flatAppSelectPrompt( return SelectedApp{}, slackerror.New(slackerror.ErrInstallationRequired) } case ShowAllApps, ShowInstalledAndNewApps: - if manifestSource.Equals(config.ManifestSourceLocal) { + isManifestSourceLocal := manifestSource.Equals(config.ManifestSourceLocal) + isBoltInstallEnabled := clients.Config.WithExperimentOn(experiment.BoltInstall) + if isManifestSourceLocal || isBoltInstallEnabled { option := Selection{ label: style.Secondary("Create a new app"), } diff --git a/internal/style/format.go b/internal/style/format.go index a4ed1f30..9101c682 100644 --- a/internal/style/format.go +++ b/internal/style/format.go @@ -268,7 +268,10 @@ func ExampleTemplatef(template string) string { // LocalRunDisplayName appends the (local) tag to apps created by the run command func LocalRunDisplayName(name string) string { - return name + " " + LocalRunNameTag + if !strings.HasSuffix(name, LocalRunNameTag) { + name = name + " " + LocalRunNameTag + } + return name } // AppIDLabel formats the appID to indicate the installation status diff --git a/internal/style/format_test.go b/internal/style/format_test.go index cac36bc9..c838d99f 100644 --- a/internal/style/format_test.go +++ b/internal/style/format_test.go @@ -382,6 +382,10 @@ func TestLocalRunDisplayNamePlain(t *testing.T) { mockAppName: "", expectedAppName: " (local)", }, + "the local tag is not appended to a name that already has it": { + mockAppName: "bizz (local)", + expectedAppName: "bizz (local)", + }, } for name, tt := range tests { t.Run(name, func(t *testing.T) {