diff --git a/cmd/manifest/sync.go b/cmd/manifest/sync.go index 3b78a359..471a2313 100644 --- a/cmd/manifest/sync.go +++ b/cmd/manifest/sync.go @@ -18,7 +18,6 @@ import ( "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/app" "github.com/slackapi/slack-cli/internal/cmdutil" - "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/manifest" "github.com/slackapi/slack-cli/internal/prompts" "github.com/slackapi/slack-cli/internal/shared" @@ -31,10 +30,9 @@ var manifestSyncFunc = manifest.Sync func NewSyncCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ - Use: "sync", - Short: "Sync the app manifest between project and app settings", - Long: "Compare the local project manifest with app settings, resolve differences, and sync both to the same state.", - Hidden: true, + Use: "sync", + Short: "Sync the app manifest between project and app settings", + Long: "Compare the local project manifest with app settings, resolve differences, and sync both to the same state.", Example: style.ExampleCommandsf([]style.ExampleCommand{ {Command: "manifest sync", Meaning: "Sync project manifest with app settings"}, {Command: "manifest sync --force", Meaning: "Push project manifest to app settings without prompting"}, @@ -42,13 +40,6 @@ func NewSyncCommand(clients *shared.ClientFactory) *cobra.Command { }), Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { - if !clients.Config.WithExperimentOn(experiment.ManifestSync) { - return slackerror.New(slackerror.ErrExperimentRequired). - WithRemediation("Enable the %s experiment with %s", - style.Highlight(string(experiment.ManifestSync)), - style.CommandText("--experiment manifest-sync"), - ) - } if clients.Config.ForceFlag && clients.Config.ForceRemoteFlag { return slackerror.New(slackerror.ErrMismatchedFlags). WithMessage("Cannot use both %s and %s flags", style.CommandText("--force"), style.CommandText("--force-remote")) diff --git a/cmd/manifest/sync_test.go b/cmd/manifest/sync_test.go index 2ca0da96..c3ca06f4 100644 --- a/cmd/manifest/sync_test.go +++ b/cmd/manifest/sync_test.go @@ -18,39 +18,24 @@ import ( "context" "testing" - "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" ) func TestSyncCommand(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ - "errors when the manifest-sync experiment is off": { + "succeeds past PreRunE with valid project": { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.AddDefaultMocks() - cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) }, - ExpectedError: slackerror.New(slackerror.ErrExperimentRequired), - }, - "passes the experiment gate when manifest-sync is enabled via flag": { - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.AddDefaultMocks() - cf.Config.ExperimentsFlag = []string{string(experiment.ManifestSync)} - cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) - }, - // We expect the command to fail downstream of the gate (no app - // selected, no SDK config), but NOT with ErrCommandUnavailable — - // the gate itself should pass. + // Command fails downstream (no app selected), but PreRunE passes. ExpectedErrorStrings: []string{}, }, "errors when both --force and --force-remote are set": { CmdArgs: []string{"--force-remote"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cm.AddDefaultMocks() - cf.Config.ExperimentsFlag = []string{string(experiment.ManifestSync)} - cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) cf.Config.ForceFlag = true }, ExpectedErrorStrings: []string{"Cannot use both", "--force", "--force-remote"}, diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index 13bf0902..ee11ab54 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -7,13 +7,13 @@ The Slack CLI has an experiment (`-e`) flag behind which we put features current The following is a list of currently available experiments. We'll remove experiments from this page if we decide they are no longer needed or once they are released, in which case we'll make an announcement about the feature's general availability in the [developer changelog](https://docs.slack.dev/changelog). - `lipgloss`: shows pretty styles. -- `manifest-sync`: resolves conflicting app manifest values. - `set-icon`: enables icon upload for non-hosted apps ([PR#469](https://github.com/slackapi/slack-cli/pull/469)). ## Experiments changelog Below is a list of updates related to experiments. +- **August 2026**: Concluded the `manifest-sync` experiment with full support for two-way manifest sync now enabled by default in the Slack CLI. - **July 2026**: Added the `manifest-sync` experiment to resolve changed app manifest values between a project and app settings. - **April 2026**: Concluded the `sandboxes` experiment with full support in the Slack CLI. Refer to the [`slack sandbox create`](/tools/slack-cli/reference/commands/slack_sandbox_create/), [`slack sandbox delete`](/tools/slack-cli/reference/commands/slack_sandbox_delete/), and [`slack sandbox list`](/tools/slack-cli/reference/commands/slack_sandbox_list/) commands for more details. - **April 2026**: Added the `set-icon` experiment to enable icon upload for non-hosted apps. diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index cec00da1..3c029c5b 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -33,9 +33,6 @@ const ( // Lipgloss experiment shows pretty styles. Lipgloss Experiment = "lipgloss" - // ManifestSync experiment enables two-way manifest sync between local and remote. - ManifestSync Experiment = "manifest-sync" - // Placeholder experiment is a placeholder for testing and does nothing... or does it? Placeholder Experiment = "placeholder" @@ -47,7 +44,6 @@ const ( // Please also add here 👇 var AllExperiments = []Experiment{ Lipgloss, - ManifestSync, Placeholder, SetIcon, } diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index 93f61285..12556422 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -38,6 +38,8 @@ const ( CreateAppManifestAndInstall = false ) +var manifestSyncFunc = manifestpkg.Sync + const additionalManifestInfoNotice = "App manifest contains some components that may require additional information" // Install installs the app to a team @@ -755,70 +757,15 @@ func shouldUpdateManifest(ctx context.Context, clients *shared.ClientFactory, ap if err != nil { return false, err } - notice := "" - switch { - case saved.Equals(hash): + if saved.Equals(hash) { return true, nil - case saved.Equals(""): - notice = "Manifest values for this app are overwritten on reinstall" - default: - notice = style.Yellow("The manifest on app settings has been changed since last update") - } - - if clients.Config.WithExperimentOn(experiment.ManifestSync) { - _, err := manifestpkg.Sync(ctx, clients, app, auth) - if err != nil { - return false, err - } - return false, nil } - clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ - Emoji: "books", - Text: "App Manifest", - Secondary: []string{notice}, - })) - if !clients.IO.IsTTY() { - return false, errorAppManifestUpdate(app, true) - } - continues, err := clients.IO.ConfirmPrompt( - ctx, - "Overwrite manifest on app settings with the project's manifest file?", - false, - ) + _, err = manifestSyncFunc(ctx, clients, app, auth) if err != nil { return false, err } - if !continues { - return false, errorAppManifestUpdate(app, false) - } - return true, nil -} - -// errorAppManifestUpdate formats an error message with app specific remediation -func errorAppManifestUpdate(app types.App, forceOption bool) *slackerror.Error { - url := "https://api.slack.com/apps" - switch { - case app.AppID != "" && app.EnterpriseID != "": - url = fmt.Sprintf("https://app.slack.com/app-settings/%s/%s/app-manifest", app.EnterpriseID, app.AppID) - case app.AppID != "" && app.TeamID != "": - url = fmt.Sprintf("https://app.slack.com/app-settings/%s/%s/app-manifest", app.TeamID, app.AppID) - case app.AppID != "": - url = fmt.Sprintf("https://api.slack.com/apps/%s", app.AppID) - } - command := style.Commandf(fmt.Sprintf("manifest --source %s", config.ManifestSourceLocal.String()), false) - remediation := []string{ - fmt.Sprintf("Check %s values with %s", config.ManifestSourceLocal.String(), command), - fmt.Sprintf("Compare app settings: %s", style.LinkText(url)), - } - if forceOption { - option := fmt.Sprintf("Write %s manifest values to app settings using `%s`", - config.ManifestSourceLocal.String(), - style.CommandText("--force"), - ) - remediation = append(remediation, option) - } - return slackerror.New(slackerror.ErrAppManifestUpdate).WithRemediation("%s", strings.Join(remediation, "\n")) + return false, nil } // Displays warning message and details and then gives user a prompt on if they want to continue or not. Returns false diff --git a/internal/pkg/apps/install_test.go b/internal/pkg/apps/install_test.go index 52a04953..516e0f1f 100644 --- a/internal/pkg/apps/install_test.go +++ b/internal/pkg/apps/install_test.go @@ -25,6 +25,7 @@ import ( "github.com/slackapi/slack-cli/internal/cache" "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/experiment" + manifestpkg "github.com/slackapi/slack-cli/internal/manifest" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackcontext" @@ -54,13 +55,13 @@ func TestInstall(t *testing.T) { mockAPIUpdateError error mockAuth types.SlackAuth mockAuthSession api.AuthSession - mockConfirmPrompt bool mockIsTTY bool mockManifestAppLocal types.SlackYaml mockManifestAppRemote types.SlackYaml mockManifestHashInitial cache.Hash mockManifestHashUpdated cache.Hash mockManifestSource config.ManifestSource + mockManifestSyncError error mockOrgGrantWorkspaceID string expectedApp types.App expectedCreate bool @@ -181,7 +182,7 @@ func TestInstall(t *testing.T) { }, expectedUpdate: true, }, - "avoid changing the manifest if a remote function runtime is specified": { + "manifest sync resolves differences for a remote function runtime app": { mockApp: types.App{ AppID: "A002", TeamID: mockTeamID, @@ -205,8 +206,6 @@ func TestInstall(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockConfirmPrompt: true, - mockIsTTY: true, mockManifestSource: config.ManifestSourceLocal, mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ @@ -231,23 +230,8 @@ func TestInstall(t *testing.T) { TeamID: mockTeamID, }, expectedInstallState: types.InstallSuccess, - expectedManifest: types.AppManifest{ - Metadata: &types.ManifestMetadata{ - MajorVersion: 1, - }, - DisplayInformation: types.DisplayInformation{ - Name: "example-2", - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - EventSubscriptions: &types.ManifestEventSubscriptions{ - RequestURL: "https://example.com", - }, - }, - }, - expectedUpdate: true, }, - "avoid changing the manifest if no function runtime is specified": { + "manifest sync resolves differences for an app without function runtime": { mockApp: types.App{ AppID: "A003", TeamID: mockTeamID, @@ -270,8 +254,6 @@ func TestInstall(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockConfirmPrompt: true, - mockIsTTY: true, mockManifestSource: config.ManifestSourceLocal, mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ @@ -290,15 +272,6 @@ func TestInstall(t *testing.T) { TeamID: mockTeamID, }, expectedInstallState: types.InstallSuccess, - expectedManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{ - Name: "example-3", - }, - Settings: &types.AppSettings{ - SocketModeEnabled: &mockTrue, - }, - }, - expectedUpdate: true, }, "create and install an app with a remote manifest": { mockApp: types.App{}, @@ -402,12 +375,13 @@ func TestInstall(t *testing.T) { mockManifestHashInitial: "pt1", mockManifestHashUpdated: "pt2", mockManifestSource: config.ManifestSourceLocal, + mockManifestSyncError: slackerror.New(slackerror.ErrAppManifestUpdate), expectedCreate: false, expectedError: slackerror.New(slackerror.ErrAppManifestUpdate), expectedInstallState: "", expectedUpdate: false, }, - "errors if the manifest cache is unset without confirmation": { + "errors if manifest sync fails": { mockApp: types.App{ AppID: "A005", TeamID: mockTeamID, @@ -434,8 +408,6 @@ func TestInstall(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockConfirmPrompt: false, - mockIsTTY: true, mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ @@ -448,6 +420,7 @@ func TestInstall(t *testing.T) { }, mockManifestHashInitial: cache.Hash(""), mockManifestHashUpdated: cache.Hash("abc"), + mockManifestSyncError: slackerror.New(slackerror.ErrAppManifestUpdate), expectedError: slackerror.New(slackerror.ErrAppManifestUpdate), expectedUpdate: false, }, @@ -574,17 +547,14 @@ func TestInstall(t *testing.T) { tc.mockAuthSession, nil, ) - if tc.mockIsTTY { - clientsMock.IO.On( - "ConfirmPrompt", - mock.Anything, - "Overwrite manifest on app settings with the project's manifest file?", - false, - ).Return( - tc.mockConfirmPrompt, - nil, - ) + originalSyncFunc := manifestSyncFunc + manifestSyncFunc = func(_ context.Context, _ *shared.ClientFactory, _ types.App, _ types.SlackAuth) (*manifestpkg.SyncResult, error) { + if tc.mockManifestSyncError != nil { + return nil, tc.mockManifestSyncError + } + return &manifestpkg.SyncResult{}, nil } + t.Cleanup(func() { manifestSyncFunc = originalSyncFunc }) manifestMock := &app.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tc.mockManifestAppLocal, nil) manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(tc.mockManifestAppRemote, nil) @@ -696,12 +666,12 @@ func TestInstallLocalApp(t *testing.T) { mockAPIUpdateError error mockAuth types.SlackAuth mockAuthSession api.AuthSession - mockConfirmPrompt bool mockIsTTY bool mockManifest types.SlackYaml mockManifestHashInitial cache.Hash mockManifestHashUpdated cache.Hash mockManifestSource config.ManifestSource + mockManifestSyncError error mockOrgGrantWorkspaceID string expectedApp types.App expectedCreate bool @@ -771,7 +741,7 @@ func TestInstallLocalApp(t *testing.T) { expectedInstallState: types.InstallSuccess, expectedUpdate: false, }, - "update and install an existing local bolt app with a remote function runtime without manifest changes": { + "manifest sync resolves differences for existing local bolt app with remote runtime": { mockApp: types.App{ AppID: "A002", TeamID: mockTeamID, @@ -793,7 +763,6 @@ func TestInstallLocalApp(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockConfirmPrompt: true, mockIsTTY: true, mockManifestSource: config.ManifestSourceLocal, mockManifest: types.SlackYaml{ @@ -826,30 +795,11 @@ func TestInstallLocalApp(t *testing.T) { TeamID: mockTeamID, UserID: mockUserID, }, - expectedManifest: types.AppManifest{ - Metadata: &types.ManifestMetadata{ - MajorVersion: 1, - }, - DisplayInformation: types.DisplayInformation{ - Name: "example-2 (local)", - }, - Features: &types.AppFeatures{ - BotUser: types.BotUser{ - DisplayName: "example-2 (local)", - }, - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - EventSubscriptions: &types.ManifestEventSubscriptions{ - RequestURL: "https://example.com", - }, - }, - }, expectedCreate: false, expectedInstallState: types.InstallSuccess, - expectedUpdate: true, + expectedUpdate: false, }, - "update and install an existing local bolt app without a function runtime without manifest changes": { + "manifest sync resolves differences for existing local bolt app without function runtime": { mockApp: types.App{ AppID: "A003", TeamID: mockTeamID, @@ -873,7 +823,6 @@ func TestInstallLocalApp(t *testing.T) { }, mockManifestSource: config.ManifestSourceLocal, mockAPIInstallState: types.InstallSuccess, - mockConfirmPrompt: true, mockIsTTY: true, mockManifest: types.SlackYaml{ AppManifest: types.AppManifest{ @@ -898,22 +847,9 @@ func TestInstallLocalApp(t *testing.T) { 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, + expectedUpdate: false, }, "skip updating and allow installing an existing bolt app with a remote manifest": { mockApp: types.App{ @@ -1191,7 +1127,6 @@ func TestInstallLocalApp(t *testing.T) { mockManifestSource: config.ManifestSourceLocal, mockManifestHashInitial: cache.Hash("123"), mockManifestHashUpdated: cache.Hash("789"), - mockConfirmPrompt: true, mockIsTTY: true, expectedApp: types.App{ AppID: "A004", @@ -1199,22 +1134,9 @@ func TestInstallLocalApp(t *testing.T) { 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, + expectedUpdate: false, }, "create and install a new bolt app when manifest is remote": { mockApp: types.App{}, @@ -1396,17 +1318,14 @@ func TestInstallLocalApp(t *testing.T) { tc.mockAuthSession, nil, ) - if tc.mockIsTTY { - clientsMock.IO.On( - "ConfirmPrompt", - mock.Anything, - "Overwrite manifest on app settings with the project's manifest file?", - false, - ).Return( - tc.mockConfirmPrompt, - nil, - ) + originalSyncFunc := manifestSyncFunc + manifestSyncFunc = func(_ context.Context, _ *shared.ClientFactory, _ types.App, _ types.SlackAuth) (*manifestpkg.SyncResult, error) { + if tc.mockManifestSyncError != nil { + return nil, tc.mockManifestSyncError + } + return &manifestpkg.SyncResult{}, nil } + t.Cleanup(func() { manifestSyncFunc = originalSyncFunc }) manifestMock := &app.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tc.mockManifest, nil) manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(tc.mockManifest, nil)