diff --git a/cmd/app/app.go b/cmd/app/app.go index 6db78f47..17f4517c 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -36,6 +36,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { {Command: "app list", Meaning: "List all teams with the app installed"}, {Command: "app settings", Meaning: "Open app settings in a web browser"}, {Command: "app uninstall", Meaning: "Uninstall an app from a team"}, + {Command: "app unlink", Meaning: "Remove a linked app from the project"}, {Command: "app delete", Meaning: "Delete an app and app info from a team"}, }), Args: cobra.NoArgs, @@ -69,6 +70,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd.AddCommand(NewListCommand(clients)) cmd.AddCommand(NewSettingsCommand(clients)) cmd.AddCommand(NewUninstallCommand(clients)) + cmd.AddCommand(NewUnlinkCommand(clients)) return cmd } diff --git a/cmd/app/unlink.go b/cmd/app/unlink.go new file mode 100644 index 00000000..9d359cf7 --- /dev/null +++ b/cmd/app/unlink.go @@ -0,0 +1,137 @@ +// Copyright 2022-2025 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 app + +import ( + "context" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/cmdutil" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +// Handle to function used for testing +var unlinkAppSelectPromptFunc = prompts.AppSelectPrompt + +// NewUnlinkCommand returns a new Cobra command for unlinking apps +func NewUnlinkCommand(clients *shared.ClientFactory) *cobra.Command { + var unlinkedApp types.App // capture app for PostRunE + + cmd := &cobra.Command{ + Use: "unlink", + Short: "Remove a linked app from the project", + Long: strings.Join([]string{ + "Unlink removes an existing app from the project.", + "", + "This command removes a saved app ID from the files of a project without deleting", + "the app from Slack.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Remove an existing app from the project", + Command: "app unlink", + }, + { + Meaning: "Remove a specific app without using prompts", + Command: "app unlink --app A0123456789", + }, + }), + PreRunE: func(cmd *cobra.Command, args []string) error { + return cmdutil.IsValidProjectDirectory(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + app, err := UnlinkCommandRunE(ctx, clients, cmd, args) + if err != nil { + return err + } + if app.AppID == "" { // user canceled + return nil + } + unlinkedApp = app // stored for PostRunE + return printUnlinkSuccess(ctx, clients, app) + }, + PostRunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkSuccess, unlinkedApp.AppID) + return nil + }, + } + return cmd +} + +// UnlinkCommandRunE executes the unlink command, prints output, and returns any errors. +func UnlinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command, args []string) (types.App, error) { + clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkStart) + + // Get the app selection from the flag or prompt + selection, err := unlinkAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps) + if err != nil { + return types.App{}, err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "unlock", + Text: "App Unlink", + Secondary: []string{ + fmt.Sprintf("App (%s) will be removed from this project", selection.App.AppID), + "The app will not be deleted from Slack", + fmt.Sprintf("You can re-link it later with %s", style.Commandf("app link", false)), + }, + })) + + // Confirm with user unless --force flag is used + if !clients.Config.ForceFlag { + proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to unlink this app?", false) + if err != nil { + return types.App{}, err + } + if !proceed { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "thumbs_up", + Text: "Your app will not be unlinked", + })) + return types.App{}, nil + } + } + + // Remove the app from the project + app, err := clients.AppClient().Remove(ctx, selection.App) + if err != nil { + return types.App{}, err + } + + return app, nil +} + +// printUnlinkSuccess displays success message after unlinking +func printUnlinkSuccess(ctx context.Context, clients *shared.ClientFactory, app types.App) error { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "unlock", + Text: "App Unlink", + Secondary: []string{ + fmt.Sprintf("Removed app %s from project", app.AppID), + fmt.Sprintf("Team: %s (%s)", app.TeamDomain, app.TeamID), + }, + })) + return nil +} diff --git a/cmd/app/unlink_test.go b/cmd/app/unlink_test.go new file mode 100644 index 00000000..2372205b --- /dev/null +++ b/cmd/app/unlink_test.go @@ -0,0 +1,138 @@ +// Copyright 2022-2025 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 app + +import ( + "context" + "fmt" + "testing" + + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func TestAppsUnlinkCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "happy path; unlink the deployed app": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareCommonUnlinkMocks(t, cf, cm) + // Mock App Selection + appSelectMock := prompts.NewAppSelectMock() + unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{ + Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain}, + App: fakeDeployedApp, + }, nil) + // Mock unlink confirmation prompt + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil) + // Mock AppClient calls + appClientMock := &app.AppClientMock{} + appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeDeployedApp, nil) + appClientMock.On("CleanUp").Return() + cf.AppClient().AppClientInterface = appClientMock + }, + ExpectedStdoutOutputs: []string{ + fmt.Sprintf("Removed app %s from project", fakeDeployedApp.AppID), + fmt.Sprintf("Team: %s", fakeDeployedApp.TeamDomain), + }, + }, + "happy path; unlink the local app": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareCommonUnlinkMocks(t, cf, cm) + // Mock App Selection + appSelectMock := prompts.NewAppSelectMock() + unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{ + Auth: types.SlackAuth{TeamDomain: fakeLocalApp.TeamDomain}, + App: fakeLocalApp, + }, nil) + // Mock unlink confirmation prompt + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil) + // Mock AppClient calls + appClientMock := &app.AppClientMock{} + appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeLocalApp, nil) + appClientMock.On("CleanUp").Return() + cf.AppClient().AppClientInterface = appClientMock + }, + ExpectedStdoutOutputs: []string{ + fmt.Sprintf("Removed app %s from project", fakeLocalApp.AppID), + fmt.Sprintf("Team: %s", fakeLocalApp.TeamDomain), + }, + }, + "sad path; unlinking the deployed app fails": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareCommonUnlinkMocks(t, cf, cm) + // Mock App Selection + appSelectMock := prompts.NewAppSelectMock() + unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{ + Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain}, + App: fakeDeployedApp, + }, nil) + // Mock unlink confirmation prompt + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil) + // Mock AppClient calls - return error + appClientMock := &app.AppClientMock{} + appClientMock.On("Remove", mock.Anything, mock.Anything).Return(types.App{}, fmt.Errorf("failed to remove app from project")) + cf.AppClient().AppClientInterface = appClientMock + }, + ExpectedError: fmt.Errorf("failed to remove app from project"), + }, + "user cancels unlink": { + CmdArgs: []string{}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareCommonUnlinkMocks(t, cf, cm) + // Mock App Selection + appSelectMock := prompts.NewAppSelectMock() + unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{ + Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain}, + App: fakeDeployedApp, + }, nil) + // Mock unlink confirmation prompt - user says no + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(false, nil) + }, + ExpectedStdoutOutputs: []string{ + "Your app will not be unlinked", + }, + }, + "errors if app selection fails": { + CmdArgs: []string{}, + ExpectedError: fmt.Errorf("failed to select app"), + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareCommonUnlinkMocks(t, cf, cm) + appSelectMock := prompts.NewAppSelectMock() + unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{}, fmt.Errorf("failed to select app")) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + cmd := NewUnlinkCommand(cf) + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil } + return cmd + }) +} + +func prepareCommonUnlinkMocks(t *testing.T, cf *shared.ClientFactory, cm *shared.ClientsMock) { + cm.AddDefaultMocks() +} diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index cc7c777c..8d9d418d 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -46,6 +46,8 @@ const ( AppLinkSuccess = "SLACK_TRACE_APP_LINK_SUCCESS" AppSettingsStart = "SLACK_TRACE_APP_SETTINGS_START" AppSettingsSuccess = "SLACK_TRACE_APP_SETTINGS_SUCCESS" + AppUnlinkStart = "SLACK_TRACE_APP_UNLINK_START" + AppUnlinkSuccess = "SLACK_TRACE_APP_UNLINK_SUCCESS" AuthListCount = "SLACK_TRACE_AUTH_LIST_COUNT" AuthListInfo = "SLACK_TRACE_AUTH_LIST_INFO" AuthListSuccess = "SLACK_TRACE_AUTH_LIST_SUCCESS"