From 0d8bfd3b15c864f88d53cc714d76cc59d671c9bd Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Wed, 27 May 2026 21:31:43 -0600 Subject: [PATCH 1/9] feat: add --app flag support to slack create for linking existing apps --- cmd/project/create.go | 51 ++++ cmd/project/create_app.go | 116 +++++++++ cmd/project/create_app_test.go | 432 +++++++++++++++++++++++++++++++++ 3 files changed, 599 insertions(+) create mode 100644 cmd/project/create_app.go create mode 100644 cmd/project/create_app_test.go diff --git a/cmd/project/create.go b/cmd/project/create.go index 549b3e0b..0c9876dc 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "math/rand" + "os" "path/filepath" "strings" "time" @@ -25,6 +26,7 @@ import ( "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -67,6 +69,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, {Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"}, {Command: "create --name my-project", Meaning: "Create a project named 'my-project'"}, {Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"}, + {Command: "create my-project -t slack-samples/bolt-js-starter-template --app A0123456789", Meaning: "Create from template and link to an existing app"}, }), Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -127,6 +130,30 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } + // --app requires --template (Mode 2 deferred) + appFlagProvided := clients.Config.AppFlag != "" && types.IsAppID(clients.Config.AppFlag) + if appFlagProvided && !templateFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --app flag requires the --template flag when used with create") + } + + // Fail fast: resolve auth and fetch manifest before creating the project + var appAuth types.SlackAuth + var remoteManifest types.SlackYaml + if appFlagProvided { + auth, err := resolveAuthForApp(ctx, clients, clients.Config.AppFlag) + if err != nil { + return err + } + appAuth = auth + + manifest, err := fetchRemoteManifest(ctx, clients, auth.Token, clients.Config.AppFlag) + if err != nil { + return err + } + remoteManifest = manifest + } + // Collect the template URL or select a starting template template, err := promptTemplateSelection(cmd, clients, categoryShortcut) if err != nil { @@ -183,6 +210,30 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] return err } + if appFlagProvided { + absProjectPath, err := filepath.Abs(appDirPath) + if err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + if nameFlagProvided { + remoteManifest.DisplayInformation.Name = displayName + } + if err := writeManifestToProject(clients.Fs, absProjectPath, remoteManifest); err != nil { + return err + } + // linkAppToProject requires the working directory to be the project + // because SaveDeployed/SaveLocal use os.Getwd() to find .slack/ + originalDir, _ := clients.Os.Getwd() + if err := os.Chdir(absProjectPath); err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + linkErr := linkAppToProject(ctx, clients, appAuth, clients.Config.AppFlag, remoteManifest) + _ = os.Chdir(originalDir) + if linkErr != nil { + return linkErr + } + } + printCreateSuccess(ctx, clients, appDirPath) return nil } diff --git a/cmd/project/create_app.go b/cmd/project/create_app.go new file mode 100644 index 00000000..cc66c5a0 --- /dev/null +++ b/cmd/project/create_app.go @@ -0,0 +1,116 @@ +// 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 project + +import ( + "context" + "encoding/json" + "path/filepath" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/afero" +) + +// resolveAuthForApp finds an authenticated workspace that has access to the given app ID. +func resolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID string) (types.SlackAuth, error) { + if clients.Config.TokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) + if err != nil { + return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) + } + return auth, nil + } + + allAuths, err := clients.Auth().Auths(ctx) + if err != nil { + return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) + } + + if len(allAuths) == 0 { + return types.SlackAuth{}, slackerror.New(slackerror.ErrNotAuthed). + WithMessage("No workspaces connected"). + WithRemediation("Run %s to sign in to a workspace that has access to app %s", style.Commandf("login", false), appID) + } + + if clients.Config.TeamFlag != "" { + for i := range allAuths { + if allAuths[i].TeamID == clients.Config.TeamFlag || allAuths[i].TeamDomain == clients.Config.TeamFlag { + if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { + return allAuths[i], nil + } + } + } + return types.SlackAuth{}, slackerror.New(slackerror.ErrTeamNotFound). + WithMessage("The specified team does not have access to app %s", appID). + WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) + } + + for i := range allAuths { + if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { + return allAuths[i], nil + } + } + + return types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound). + WithMessage("No authenticated workspace has access to app %s", appID). + WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) +} + +// fetchRemoteManifest retrieves the app manifest from the platform via apps.manifest.export. +func fetchRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token string, appID string) (types.SlackYaml, error) { + manifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) + if err != nil { + return types.SlackYaml{}, slackerror.New(slackerror.ErrInvalidManifest). + WithMessage("Failed to fetch manifest for app %s", appID) + } + return manifest, nil +} + +// writeManifestToProject writes the fetched manifest JSON to the project directory. +func writeManifestToProject(fs afero.Fs, projectPath string, manifest types.SlackYaml) error { + manifestData, err := json.MarshalIndent(manifest.AppManifest, "", " ") + if err != nil { + return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). + WithMessage("Failed to serialize app manifest") + } + + manifestPath := filepath.Join(projectPath, "manifest.json") + if err := afero.WriteFile(fs, manifestPath, append(manifestData, '\n'), 0644); err != nil { + return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). + WithMessage("Failed to write manifest to project") + } + return nil +} + +// linkAppToProject saves the app to the project's apps JSON file. +// Defaults to local/dev unless the manifest explicitly uses a hosted runtime. +func linkAppToProject(ctx context.Context, clients *shared.ClientFactory, auth types.SlackAuth, appID string, manifest types.SlackYaml) error { + app := types.App{ + AppID: appID, + TeamID: auth.TeamID, + TeamDomain: auth.TeamDomain, + EnterpriseID: auth.EnterpriseID, + } + + if manifest.IsFunctionRuntimeSlackHosted() { + return clients.AppClient().SaveDeployed(ctx, app) + } + app.IsDev = true + app.UserID = auth.UserID + return clients.AppClient().SaveLocal(ctx, app) +} diff --git a/cmd/project/create_app_test.go b/cmd/project/create_app_test.go new file mode 100644 index 00000000..678a334d --- /dev/null +++ b/cmd/project/create_app_test.go @@ -0,0 +1,432 @@ +// 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 project + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/iostreams" + "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/afero" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestCreateCommand_AppFlag(t *testing.T) { + var createClientMock *CreateClientMock + + testutil.TableTestCommand(t, testutil.CommandTests{ + "app flag without template flag returns error": { + CmdArgs: []string{"my-app", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires the --template flag when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with template fetches manifest and links as local by default": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("SaveLocal", mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + // Create a real temp directory that os.Chdir can navigate to + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with slack-hosted runtime links as deployed": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.SlackHosted, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { + return a.AppID == "A0123456789" && a.TeamID == "T123" && !a.IsDev + })).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + // Create a real temp directory that os.Chdir can navigate to + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with local runtime links as dev app": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.LocallyRun, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("SaveLocal", mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + // Create a real temp directory that os.Chdir can navigate to + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "name flag overrides manifest display name": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--name", "Custom Name"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "Original Remote Name", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("SaveLocal", mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + call := createClientMock.Calls[0] + projectDir := call.ReturnArguments[0].(string) + data, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + displayInfo := result["display_information"].(map[string]any) + assert.Equal(t, "Custom Name", displayInfo["name"]) + }, + }, + "app flag with no authenticated workspace returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"No workspaces connected"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with inaccessible app returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, assert.AnError) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"No authenticated workspace has access to app A0123456789"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with manifest export failure returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{}, assert.AnError) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"Failed to fetch manifest for app A0123456789"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_resolveAuthForApp(t *testing.T) { + tests := map[string]struct { + setupAuth func(*shared.ClientsMock) + appID string + expectErr bool + expectTeamID string + }{ + "returns first auth that has access": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, + {Token: "token-b", TeamID: "T002", TeamDomain: "team-b"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). + Return(api.GetAppStatusResult{}, assert.AnError) + cm.API.On("GetAppStatus", mock.Anything, "token-b", []string{"A111"}, "T002"). + Return(api.GetAppStatusResult{}, nil) + }, + appID: "A111", + expectTeamID: "T002", + }, + "returns error when no auths": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + }, + appID: "A111", + expectErr: true, + }, + "returns error when no auth has access": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). + Return(api.GetAppStatusResult{}, assert.AnError) + }, + appID: "A111", + expectErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + tc.setupAuth(cm) + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + auth, err := resolveAuthForApp(t.Context(), clients, tc.appID) + if tc.expectErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectTeamID, auth.TeamID) + } + }) + } +} + +func Test_writeManifestToProject(t *testing.T) { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("test-project", 0755) + + manifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "Test App", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + } + + err := writeManifestToProject(fs, "test-project", manifest) + require.NoError(t, err) + + data, err := afero.ReadFile(fs, "test-project/manifest.json") + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + + displayInfo := result["display_information"].(map[string]any) + assert.Equal(t, "Test App", displayInfo["name"]) +} + +func Test_linkAppToProject(t *testing.T) { + tests := map[string]struct { + runtime types.FunctionRuntime + expectDev bool + }{ + "links as local for remote runtime": { + runtime: types.Remote, + expectDev: true, + }, + "links as local for local runtime": { + runtime: types.LocallyRun, + expectDev: true, + }, + "links as deployed for slack-hosted runtime": { + runtime: types.SlackHosted, + expectDev: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + appClientMock := &app.AppClientMock{} + if tc.expectDev { + appClientMock.On("SaveLocal", mock.Anything).Return(nil) + } else { + appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { + return a.AppID == "A999" && a.TeamID == "T123" && !a.IsDev + })).Return(nil) + } + clients.AppClient().AppClientInterface = appClientMock + + auth := types.SlackAuth{ + Token: "xoxp-token", + TeamID: "T123", + TeamDomain: "my-team", + UserID: "U456", + } + manifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: tc.runtime, + }, + }, + } + + err := linkAppToProject(t.Context(), clients, auth, "A999", manifest) + require.NoError(t, err) + + if tc.expectDev { + appClientMock.AssertCalled(t, "SaveLocal", mock.Anything) + } else { + appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) + } + }) + } +} From da2eb758a1cfe7ae2f99b54ac977a6cc24bd704f Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 1 Jun 2026 11:59:05 -0500 Subject: [PATCH 2/9] address pr feedback --- cmd/project/create.go | 27 +- cmd/project/create_app_test.go | 432 ------------------ cmd/project/create_test.go | 313 +++++++++++++ .../pkg/apps/link.go | 85 +++- internal/pkg/apps/link_test.go | 318 +++++++++++++ 5 files changed, 715 insertions(+), 460 deletions(-) delete mode 100644 cmd/project/create_app_test.go rename cmd/project/create_app.go => internal/pkg/apps/link.go (62%) create mode 100644 internal/pkg/apps/link_test.go diff --git a/cmd/project/create.go b/cmd/project/create.go index 0c9876dc..c7089f03 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -24,6 +24,7 @@ import ( "time" "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/pkg/apps" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -39,6 +40,7 @@ var createGitBranchFlag string var createAppNameFlag string var createListFlag bool var createSubdirFlag string +var createEnvironmentFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client @@ -84,6 +86,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, cmd.Flags().StringVarP(&createAppNameFlag, "name", "n", "", "name for your app (overrides the name argument)") cmd.Flags().BoolVar(&createListFlag, "list", false, "list available app templates") cmd.Flags().StringVar(&createSubdirFlag, "subdir", "", "subdirectory in the template to use as project") + cmd.Flags().StringVarP(&createEnvironmentFlag, "environment", "E", "", "environment to save existing app (local, deployed)") return cmd } @@ -137,17 +140,23 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --app flag requires the --template flag when used with create") } + // --environment requires --app + if cmd.Flags().Changed("environment") && !appFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --environment flag requires the --app flag when used with create") + } + // Fail fast: resolve auth and fetch manifest before creating the project var appAuth types.SlackAuth var remoteManifest types.SlackYaml if appFlagProvided { - auth, err := resolveAuthForApp(ctx, clients, clients.Config.AppFlag) + auth, err := apps.ResolveAuthForApp(ctx, clients, clients.Config.AppFlag) if err != nil { return err } appAuth = auth - manifest, err := fetchRemoteManifest(ctx, clients, auth.Token, clients.Config.AppFlag) + manifest, err := apps.FetchRemoteManifest(ctx, clients, auth.Token, clients.Config.AppFlag) if err != nil { return err } @@ -174,7 +183,13 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] // Prompt for app name if not provided via flag or argument if appPathArg == "" { - if clients.IO.IsTTY() { + if appFlagProvided { + if remoteManifest.DisplayInformation.Name != "" { + appPathArg = remoteManifest.DisplayInformation.Name + } else { + appPathArg = generateRandomAppName() + } + } else if clients.IO.IsTTY() { defaultName := generateRandomAppName() name, err := clients.IO.InputPrompt(ctx, "Name your app:", iostreams.InputPromptConfig{ Placeholder: defaultName, @@ -218,16 +233,16 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] if nameFlagProvided { remoteManifest.DisplayInformation.Name = displayName } - if err := writeManifestToProject(clients.Fs, absProjectPath, remoteManifest); err != nil { + if err := apps.WriteManifestToProject(clients.Fs, absProjectPath, remoteManifest); err != nil { return err } - // linkAppToProject requires the working directory to be the project + // LinkAppToProject requires the working directory to be the project // because SaveDeployed/SaveLocal use os.Getwd() to find .slack/ originalDir, _ := clients.Os.Getwd() if err := os.Chdir(absProjectPath); err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - linkErr := linkAppToProject(ctx, clients, appAuth, clients.Config.AppFlag, remoteManifest) + linkErr := apps.LinkAppToProject(ctx, clients, appAuth, clients.Config.AppFlag, remoteManifest, createEnvironmentFlag) _ = os.Chdir(originalDir) if linkErr != nil { return linkErr diff --git a/cmd/project/create_app_test.go b/cmd/project/create_app_test.go deleted file mode 100644 index 678a334d..00000000 --- a/cmd/project/create_app_test.go +++ /dev/null @@ -1,432 +0,0 @@ -// 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 project - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/slackapi/slack-cli/internal/api" - "github.com/slackapi/slack-cli/internal/app" - "github.com/slackapi/slack-cli/internal/iostreams" - "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/afero" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -func TestCreateCommand_AppFlag(t *testing.T) { - var createClientMock *CreateClientMock - - testutil.TableTestCommand(t, testutil.CommandTests{ - "app flag without template flag returns error": { - CmdArgs: []string{"my-app", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"The --app flag requires the --template flag when used with create"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with template fetches manifest and links as local by default": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("SaveLocal", mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - // Create a real temp directory that os.Chdir can navigate to - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with slack-hosted runtime links as deployed": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.SlackHosted, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { - return a.AppID == "A0123456789" && a.TeamID == "T123" && !a.IsDev - })).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - // Create a real temp directory that os.Chdir can navigate to - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with local runtime links as dev app": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.LocallyRun, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("SaveLocal", mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - // Create a real temp directory that os.Chdir can navigate to - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "name flag overrides manifest display name": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--name", "Custom Name"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{ - Name: "Original Remote Name", - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("SaveLocal", mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - call := createClientMock.Calls[0] - projectDir := call.ReturnArguments[0].(string) - data, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - displayInfo := result["display_information"].(map[string]any) - assert.Equal(t, "Custom Name", displayInfo["name"]) - }, - }, - "app flag with no authenticated workspace returns error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"No workspaces connected"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with inaccessible app returns error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, assert.AnError) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"No authenticated workspace has access to app A0123456789"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with manifest export failure returns error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{}, assert.AnError) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"Failed to fetch manifest for app A0123456789"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - }, func(cf *shared.ClientFactory) *cobra.Command { - return NewCreateCommand(cf) - }) -} - -func Test_resolveAuthForApp(t *testing.T) { - tests := map[string]struct { - setupAuth func(*shared.ClientsMock) - appID string - expectErr bool - expectTeamID string - }{ - "returns first auth that has access": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, - {Token: "token-b", TeamID: "T002", TeamDomain: "team-b"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). - Return(api.GetAppStatusResult{}, assert.AnError) - cm.API.On("GetAppStatus", mock.Anything, "token-b", []string{"A111"}, "T002"). - Return(api.GetAppStatusResult{}, nil) - }, - appID: "A111", - expectTeamID: "T002", - }, - "returns error when no auths": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) - }, - appID: "A111", - expectErr: true, - }, - "returns error when no auth has access": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). - Return(api.GetAppStatusResult{}, assert.AnError) - }, - appID: "A111", - expectErr: true, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - tc.setupAuth(cm) - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - auth, err := resolveAuthForApp(t.Context(), clients, tc.appID) - if tc.expectErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tc.expectTeamID, auth.TeamID) - } - }) - } -} - -func Test_writeManifestToProject(t *testing.T) { - fs := afero.NewMemMapFs() - _ = fs.MkdirAll("test-project", 0755) - - manifest := types.SlackYaml{ - AppManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{ - Name: "Test App", - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - } - - err := writeManifestToProject(fs, "test-project", manifest) - require.NoError(t, err) - - data, err := afero.ReadFile(fs, "test-project/manifest.json") - require.NoError(t, err) - - var result map[string]any - err = json.Unmarshal(data, &result) - require.NoError(t, err) - - displayInfo := result["display_information"].(map[string]any) - assert.Equal(t, "Test App", displayInfo["name"]) -} - -func Test_linkAppToProject(t *testing.T) { - tests := map[string]struct { - runtime types.FunctionRuntime - expectDev bool - }{ - "links as local for remote runtime": { - runtime: types.Remote, - expectDev: true, - }, - "links as local for local runtime": { - runtime: types.LocallyRun, - expectDev: true, - }, - "links as deployed for slack-hosted runtime": { - runtime: types.SlackHosted, - expectDev: false, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - appClientMock := &app.AppClientMock{} - if tc.expectDev { - appClientMock.On("SaveLocal", mock.Anything).Return(nil) - } else { - appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { - return a.AppID == "A999" && a.TeamID == "T123" && !a.IsDev - })).Return(nil) - } - clients.AppClient().AppClientInterface = appClientMock - - auth := types.SlackAuth{ - Token: "xoxp-token", - TeamID: "T123", - TeamDomain: "my-team", - UserID: "U456", - } - manifest := types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: tc.runtime, - }, - }, - } - - err := linkAppToProject(t.Context(), clients, auth, "A999", manifest) - require.NoError(t, err) - - if tc.expectDev { - appClientMock.AssertCalled(t, "SaveLocal", mock.Anything) - } else { - appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) - } - }) - } -} diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index d2fa65f1..162525e0 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,14 +16,21 @@ package project import ( "context" + "encoding/json" + "os" + "path/filepath" "testing" + "github.com/slackapi/slack-cli/internal/api" + "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" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" "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" @@ -853,3 +860,309 @@ func TestCreateCommand_confirmExternalTemplateSelection(t *testing.T) { }) } } + +func TestCreateCommand_AppFlag(t *testing.T) { + var createClientMock *CreateClientMock + + testutil.TableTestCommand(t, testutil.CommandTests{ + "app flag without template flag returns error": { + CmdArgs: []string{"my-app", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires the --template flag when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with template fetches manifest and links as local by default": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with slack-hosted runtime links as deployed": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.SlackHosted, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { + return a.AppID == "A0123456789" && a.TeamID == "T123" && !a.IsDev + })).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with local runtime links as dev app": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.LocallyRun, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "name flag overrides manifest display name": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--name", "Custom Name"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "Original Remote Name", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + call := createClientMock.Calls[0] + projectDir := call.ReturnArguments[0].(string) + data, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) + require.NoError(t, err) + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + displayInfo := result["display_information"].(map[string]any) + assert.Equal(t, "Custom Name", displayInfo["name"]) + }, + }, + "app flag with no authenticated workspace returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"No workspaces connected"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with inaccessible app returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, assert.AnError) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"No authenticated workspace has access to app A0123456789"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with manifest export failure surfaces original error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{}, assert.AnError) + + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{assert.AnError.Error()}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "environment flag without app flag returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--environment", "deployed"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --environment flag requires the --app flag when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "environment flag deployed overrides manifest inference": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). + Return(api.GetAppStatusResult{}, nil) + + manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). + Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { + return a.AppID == "A0123456789" && !a.IsDev + })).Return(nil) + cf.AppClient().AppClientInterface = appClientMock + + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "my-app") + require.NoError(t, os.MkdirAll(projectDir, 0755)) + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + }, + 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) + }) +} diff --git a/cmd/project/create_app.go b/internal/pkg/apps/link.go similarity index 62% rename from cmd/project/create_app.go rename to internal/pkg/apps/link.go index cc66c5a0..69d6ebd4 100644 --- a/cmd/project/create_app.go +++ b/internal/pkg/apps/link.go @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -package project +package apps import ( "context" "encoding/json" "path/filepath" + "strings" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -26,8 +27,8 @@ import ( "github.com/spf13/afero" ) -// resolveAuthForApp finds an authenticated workspace that has access to the given app ID. -func resolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID string) (types.SlackAuth, error) { +// ResolveAuthForApp finds an authenticated workspace that has access to the given app ID. +func ResolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID string) (types.SlackAuth, error) { if clients.Config.TokenFlag != "" { auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) if err != nil { @@ -71,18 +72,50 @@ func resolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) } -// fetchRemoteManifest retrieves the app manifest from the platform via apps.manifest.export. -func fetchRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token string, appID string) (types.SlackYaml, error) { +// LinkAppToProject saves the app to the project's apps JSON file. +// The environment parameter decides local vs deployed: "local", "deployed", or +// empty string to infer from the manifest runtime. +func LinkAppToProject(ctx context.Context, clients *shared.ClientFactory, auth types.SlackAuth, appID string, manifest types.SlackYaml, environment string) error { + app := types.App{ + AppID: appID, + TeamID: auth.TeamID, + TeamDomain: auth.TeamDomain, + EnterpriseID: auth.EnterpriseID, + } + + isDeployed := false + switch strings.ToLower(environment) { + case "deployed": + isDeployed = true + case "local": + isDeployed = false + case "": + isDeployed = manifest.IsFunctionRuntimeSlackHosted() + default: + return slackerror.New(slackerror.ErrMismatchedFlags). + WithRemediation("The --environment flag must be either 'local' or 'deployed'") + } + + if !isDeployed { + app.IsDev = true + app.UserID = auth.UserID + } + + return SaveAppToProject(ctx, clients, app) +} + +// FetchRemoteManifest retrieves the app manifest from the platform via apps.manifest.export. +func FetchRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token string, appID string) (types.SlackYaml, error) { manifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) if err != nil { - return types.SlackYaml{}, slackerror.New(slackerror.ErrInvalidManifest). + return types.SlackYaml{}, slackerror.Wrap(err, slackerror.ErrInvalidManifest). WithMessage("Failed to fetch manifest for app %s", appID) } return manifest, nil } -// writeManifestToProject writes the fetched manifest JSON to the project directory. -func writeManifestToProject(fs afero.Fs, projectPath string, manifest types.SlackYaml) error { +// WriteManifestToProject writes the fetched manifest JSON to the project directory. +func WriteManifestToProject(fs afero.Fs, projectPath string, manifest types.SlackYaml) error { manifestData, err := json.MarshalIndent(manifest.AppManifest, "", " ") if err != nil { return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). @@ -97,20 +130,28 @@ func writeManifestToProject(fs afero.Fs, projectPath string, manifest types.Slac return nil } -// linkAppToProject saves the app to the project's apps JSON file. -// Defaults to local/dev unless the manifest explicitly uses a hosted runtime. -func linkAppToProject(ctx context.Context, clients *shared.ClientFactory, auth types.SlackAuth, appID string, manifest types.SlackYaml) error { - app := types.App{ - AppID: appID, - TeamID: auth.TeamID, - TeamDomain: auth.TeamDomain, - EnterpriseID: auth.EnterpriseID, +// SaveAppToProject writes the linked app to the project's apps JSON file, +// checking for conflicts before saving unless --force is set. +func SaveAppToProject(ctx context.Context, clients *shared.ClientFactory, app types.App) error { + deploy, err := clients.AppClient().GetDeployed(ctx, app.TeamID) + if err != nil { + return err } - - if manifest.IsFunctionRuntimeSlackHosted() { - return clients.AppClient().SaveDeployed(ctx, app) + local, err := clients.AppClient().GetLocal(ctx, app.TeamID) + if err != nil { + return err + } + switch app.IsDev { + case true: + if clients.Config.ForceFlag || (local.IsNew() && deploy.AppID != app.AppID) { + return clients.AppClient().SaveLocal(ctx, app) + } + case false: + if clients.Config.ForceFlag || (deploy.IsNew() && local.AppID != app.AppID) { + return clients.AppClient().SaveDeployed(ctx, app) + } } - app.IsDev = true - app.UserID = auth.UserID - return clients.AppClient().SaveLocal(ctx, app) + return slackerror.New(slackerror.ErrAppFound). + WithMessage("A saved app was found and cannot be overwritten"). + WithRemediation("Remove the app from this project or try again with %s", style.Bold("--force")) } diff --git a/internal/pkg/apps/link_test.go b/internal/pkg/apps/link_test.go new file mode 100644 index 00000000..d2967557 --- /dev/null +++ b/internal/pkg/apps/link_test.go @@ -0,0 +1,318 @@ +// 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 apps + +import ( + "encoding/json" + "testing" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_FetchRemoteManifest(t *testing.T) { + tests := map[string]struct { + manifest types.SlackYaml + mockErr error + expectErr bool + }{ + "returns manifest on success": { + manifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Test App"}, + }, + }, + }, + "preserves original error on failure": { + mockErr: assert.AnError, + expectErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + manifestMock := clients.AppClient().Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestRemote", mock.Anything, "token", "A123"). + Return(tc.manifest, tc.mockErr) + + result, err := FetchRemoteManifest(t.Context(), clients, "token", "A123") + if tc.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), assert.AnError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, "Test App", result.DisplayInformation.Name) + } + }) + } +} + +func Test_WriteManifestToProject(t *testing.T) { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("test-project", 0755) + + manifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "My App", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.Remote, + }, + }, + } + + err := WriteManifestToProject(fs, "test-project", manifest) + require.NoError(t, err) + + data, err := afero.ReadFile(fs, "test-project/manifest.json") + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + + displayInfo := result["display_information"].(map[string]any) + assert.Equal(t, "My App", displayInfo["name"]) +} + +func Test_SaveAppToProject(t *testing.T) { + tests := map[string]struct { + app types.App + existingDeploy types.App + existingLocal types.App + forceFlag bool + expectSave string + expectErr bool + }{ + "saves local dev app when no existing apps": { + app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, + existingDeploy: types.NewApp(), + existingLocal: types.NewApp(), + expectSave: "local", + }, + "saves deployed app when no existing apps": { + app: types.App{AppID: "A1", TeamID: "T1", IsDev: false}, + existingDeploy: types.NewApp(), + existingLocal: types.NewApp(), + expectSave: "deployed", + }, + "returns error when local app exists and same app in deploy": { + app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, + existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, + existingLocal: types.App{AppID: "A1", TeamID: "T1"}, + expectErr: true, + }, + "saves local with force when conflict exists": { + app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, + existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, + existingLocal: types.App{AppID: "A1", TeamID: "T1"}, + forceFlag: true, + expectSave: "local", + }, + "saves deployed with force when conflict exists": { + app: types.App{AppID: "A1", TeamID: "T1", IsDev: false}, + existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, + existingLocal: types.App{AppID: "A1", TeamID: "T1"}, + forceFlag: true, + expectSave: "deployed", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + clients.Config.ForceFlag = tc.forceFlag + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, tc.app.TeamID).Return(tc.existingDeploy, nil) + appClientMock.On("GetLocal", mock.Anything, tc.app.TeamID).Return(tc.existingLocal, nil) + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) + appClientMock.On("SaveDeployed", mock.Anything, mock.Anything).Return(nil) + clients.AppClient().AppClientInterface = appClientMock + + err := SaveAppToProject(t.Context(), clients, tc.app) + if tc.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be overwritten") + } else { + require.NoError(t, err) + switch tc.expectSave { + case "local": + appClientMock.AssertCalled(t, "SaveLocal", mock.Anything, mock.Anything) + case "deployed": + appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) + } + } + }) + } +} + +func Test_ResolveAuthForApp(t *testing.T) { + tests := map[string]struct { + setupAuth func(*shared.ClientsMock) + appID string + expectErr bool + expectTeamID string + }{ + "returns first auth that has access": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, + {Token: "token-b", TeamID: "T002", TeamDomain: "team-b"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). + Return(api.GetAppStatusResult{}, assert.AnError) + cm.API.On("GetAppStatus", mock.Anything, "token-b", []string{"A111"}, "T002"). + Return(api.GetAppStatusResult{}, nil) + }, + appID: "A111", + expectTeamID: "T002", + }, + "returns error when no auths": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + }, + appID: "A111", + expectErr: true, + }, + "returns error when no auth has access": { + setupAuth: func(cm *shared.ClientsMock) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, + }, nil) + cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). + Return(api.GetAppStatusResult{}, assert.AnError) + }, + appID: "A111", + expectErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + tc.setupAuth(cm) + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + auth, err := ResolveAuthForApp(t.Context(), clients, tc.appID) + if tc.expectErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectTeamID, auth.TeamID) + } + }) + } +} + +func Test_LinkAppToProject(t *testing.T) { + tests := map[string]struct { + runtime types.FunctionRuntime + environment string + expectDev bool + }{ + "infers local for remote runtime": { + runtime: types.Remote, + expectDev: true, + }, + "infers local for local runtime": { + runtime: types.LocallyRun, + expectDev: true, + }, + "infers deployed for slack-hosted runtime": { + runtime: types.SlackHosted, + expectDev: false, + }, + "environment flag deployed overrides manifest": { + runtime: types.Remote, + environment: "deployed", + expectDev: false, + }, + "environment flag local overrides manifest": { + runtime: types.SlackHosted, + environment: "local", + expectDev: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) + appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) + if tc.expectDev { + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) + } else { + appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { + return a.AppID == "A999" && a.TeamID == "T123" && !a.IsDev + })).Return(nil) + } + clients.AppClient().AppClientInterface = appClientMock + + auth := types.SlackAuth{ + Token: "xoxp-token", + TeamID: "T123", + TeamDomain: "my-team", + UserID: "U456", + } + manifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{ + FunctionRuntime: tc.runtime, + }, + }, + } + + err := LinkAppToProject(t.Context(), clients, auth, "A999", manifest, tc.environment) + require.NoError(t, err) + + if tc.expectDev { + appClientMock.AssertCalled(t, "SaveLocal", mock.Anything, mock.Anything) + } else { + appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) + } + }) + } +} + +func Test_LinkAppToProject_invalidEnvironment(t *testing.T) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + clients := shared.NewClientFactory(cm.MockClientFactory()) + + auth := types.SlackAuth{Token: "token", TeamID: "T1"} + manifest := types.SlackYaml{} + + err := LinkAppToProject(t.Context(), clients, auth, "A1", manifest, "invalid") + require.Error(t, err) + assert.Contains(t, err.Error(), "local") +} From 906346a67391f8fab69ed84d01bad2ff0f206dc3 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 8 Jun 2026 11:35:48 -0400 Subject: [PATCH 3/9] simplify app creation with app link cmd --- cmd/project/create.go | 39 +--- cmd/project/create_test.go | 263 ++------------------------- internal/pkg/apps/link.go | 157 ---------------- internal/pkg/apps/link_test.go | 318 --------------------------------- 4 files changed, 18 insertions(+), 759 deletions(-) delete mode 100644 internal/pkg/apps/link.go delete mode 100644 internal/pkg/apps/link_test.go diff --git a/cmd/project/create.go b/cmd/project/create.go index c7089f03..6fd0cc74 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -23,8 +23,8 @@ import ( "strings" "time" + "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/internal/iostreams" - "github.com/slackapi/slack-cli/internal/pkg/apps" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -133,7 +133,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } - // --app requires --template (Mode 2 deferred) + // --app requires --template appFlagProvided := clients.Config.AppFlag != "" && types.IsAppID(clients.Config.AppFlag) if appFlagProvided && !templateFlagProvided { return slackerror.New(slackerror.ErrMismatchedFlags). @@ -146,23 +146,6 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --environment flag requires the --app flag when used with create") } - // Fail fast: resolve auth and fetch manifest before creating the project - var appAuth types.SlackAuth - var remoteManifest types.SlackYaml - if appFlagProvided { - auth, err := apps.ResolveAuthForApp(ctx, clients, clients.Config.AppFlag) - if err != nil { - return err - } - appAuth = auth - - manifest, err := apps.FetchRemoteManifest(ctx, clients, auth.Token, clients.Config.AppFlag) - if err != nil { - return err - } - remoteManifest = manifest - } - // Collect the template URL or select a starting template template, err := promptTemplateSelection(cmd, clients, categoryShortcut) if err != nil { @@ -183,13 +166,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] // Prompt for app name if not provided via flag or argument if appPathArg == "" { - if appFlagProvided { - if remoteManifest.DisplayInformation.Name != "" { - appPathArg = remoteManifest.DisplayInformation.Name - } else { - appPathArg = generateRandomAppName() - } - } else if clients.IO.IsTTY() { + if clients.IO.IsTTY() { defaultName := generateRandomAppName() name, err := clients.IO.InputPrompt(ctx, "Name your app:", iostreams.InputPromptConfig{ Placeholder: defaultName, @@ -230,19 +207,11 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] if err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - if nameFlagProvided { - remoteManifest.DisplayInformation.Name = displayName - } - if err := apps.WriteManifestToProject(clients.Fs, absProjectPath, remoteManifest); err != nil { - return err - } - // LinkAppToProject requires the working directory to be the project - // because SaveDeployed/SaveLocal use os.Getwd() to find .slack/ originalDir, _ := clients.Os.Getwd() if err := os.Chdir(absProjectPath); err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - linkErr := apps.LinkAppToProject(ctx, clients, appAuth, clients.Config.AppFlag, remoteManifest, createEnvironmentFlag) + linkErr := app.LinkExistingApp(ctx, clients, &types.App{}, false) _ = os.Chdir(originalDir) if linkErr != nil { return linkErr diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 162525e0..9265882b 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,7 +16,6 @@ package project import ( "context" - "encoding/json" "os" "path/filepath" "testing" @@ -876,239 +875,6 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, - "app flag with template fetches manifest and links as local by default": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with slack-hosted runtime links as deployed": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.SlackHosted, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { - return a.AppID == "A0123456789" && a.TeamID == "T123" && !a.IsDev - })).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with local runtime links as dev app": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.LocallyRun, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "name flag overrides manifest display name": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--name", "Custom Name"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{ - Name: "Original Remote Name", - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - call := createClientMock.Calls[0] - projectDir := call.ReturnArguments[0].(string) - data, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) - require.NoError(t, err) - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - displayInfo := result["display_information"].(map[string]any) - assert.Equal(t, "Custom Name", displayInfo["name"]) - }, - }, - "app flag with no authenticated workspace returns error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"No workspaces connected"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with inaccessible app returns error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, assert.AnError) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{"No authenticated workspace has access to app A0123456789"}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, - "app flag with manifest export failure surfaces original error": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, - Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{}, assert.AnError) - - createClientMock = new(CreateClientMock) - CreateFunc = createClientMock.Create - }, - ExpectedErrorStrings: []string{assert.AnError.Error()}, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - }, - }, "environment flag without app flag returns error": { CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--environment", "deployed"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { @@ -1120,46 +886,45 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, - "environment flag deployed overrides manifest inference": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"}, + "app flag with template creates project then runs link": { + 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) { cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + // LinkExistingApp calls PromptTeamSlackAuth which calls Auths cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, }, nil) + // LinkExistingApp validates app access cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). Return(api.GetAppStatusResult{}, nil) - manifestMock := cf.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-test-token", "A0123456789"). - Return(types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - }, nil) - + // LinkExistingApp calls saveAppToJSON appClientMock := &app.AppClientMock{} appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { - return a.AppID == "A0123456789" && !a.IsDev - })).Return(nil) + appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) cf.AppClient().AppClientInterface = appClientMock + // Create project directory on real filesystem for os.Chdir tmpDir := t.TempDir() projectDir := filepath.Join(tmpDir, "my-app") require.NoError(t, os.MkdirAll(projectDir, 0755)) + // Set up mock Os.Getwd to return the project dir (used by ProjectConfig) + cm.Os.On("Getwd").Return(projectDir, nil) + // Create .slack/hooks.json on mock filesystem so GetProjectDirPath succeeds + require.NoError(t, cm.Fs.MkdirAll(filepath.Join(projectDir, ".slack"), 0755)) + require.NoError(t, afero.WriteFile(cm.Fs, filepath.Join(projectDir, ".slack", "hooks.json"), []byte("{}"), 0644)) + createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) CreateFunc = createClientMock.Create }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + cm.API.AssertCalled(t, "GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123") }, }, }, func(cf *shared.ClientFactory) *cobra.Command { diff --git a/internal/pkg/apps/link.go b/internal/pkg/apps/link.go deleted file mode 100644 index 69d6ebd4..00000000 --- a/internal/pkg/apps/link.go +++ /dev/null @@ -1,157 +0,0 @@ -// 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 apps - -import ( - "context" - "encoding/json" - "path/filepath" - "strings" - - "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" - "github.com/slackapi/slack-cli/internal/slackerror" - "github.com/slackapi/slack-cli/internal/style" - "github.com/spf13/afero" -) - -// ResolveAuthForApp finds an authenticated workspace that has access to the given app ID. -func ResolveAuthForApp(ctx context.Context, clients *shared.ClientFactory, appID string) (types.SlackAuth, error) { - if clients.Config.TokenFlag != "" { - auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) - if err != nil { - return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) - } - return auth, nil - } - - allAuths, err := clients.Auth().Auths(ctx) - if err != nil { - return types.SlackAuth{}, slackerror.Wrap(err, slackerror.ErrNotAuthed) - } - - if len(allAuths) == 0 { - return types.SlackAuth{}, slackerror.New(slackerror.ErrNotAuthed). - WithMessage("No workspaces connected"). - WithRemediation("Run %s to sign in to a workspace that has access to app %s", style.Commandf("login", false), appID) - } - - if clients.Config.TeamFlag != "" { - for i := range allAuths { - if allAuths[i].TeamID == clients.Config.TeamFlag || allAuths[i].TeamDomain == clients.Config.TeamFlag { - if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { - return allAuths[i], nil - } - } - } - return types.SlackAuth{}, slackerror.New(slackerror.ErrTeamNotFound). - WithMessage("The specified team does not have access to app %s", appID). - WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) - } - - for i := range allAuths { - if _, err := clients.API().GetAppStatus(ctx, allAuths[i].Token, []string{appID}, allAuths[i].TeamID); err == nil { - return allAuths[i], nil - } - } - - return types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound). - WithMessage("No authenticated workspace has access to app %s", appID). - WithRemediation("Run %s to sign in to the workspace that owns this app", style.Commandf("login", false)) -} - -// LinkAppToProject saves the app to the project's apps JSON file. -// The environment parameter decides local vs deployed: "local", "deployed", or -// empty string to infer from the manifest runtime. -func LinkAppToProject(ctx context.Context, clients *shared.ClientFactory, auth types.SlackAuth, appID string, manifest types.SlackYaml, environment string) error { - app := types.App{ - AppID: appID, - TeamID: auth.TeamID, - TeamDomain: auth.TeamDomain, - EnterpriseID: auth.EnterpriseID, - } - - isDeployed := false - switch strings.ToLower(environment) { - case "deployed": - isDeployed = true - case "local": - isDeployed = false - case "": - isDeployed = manifest.IsFunctionRuntimeSlackHosted() - default: - return slackerror.New(slackerror.ErrMismatchedFlags). - WithRemediation("The --environment flag must be either 'local' or 'deployed'") - } - - if !isDeployed { - app.IsDev = true - app.UserID = auth.UserID - } - - return SaveAppToProject(ctx, clients, app) -} - -// FetchRemoteManifest retrieves the app manifest from the platform via apps.manifest.export. -func FetchRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token string, appID string) (types.SlackYaml, error) { - manifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) - if err != nil { - return types.SlackYaml{}, slackerror.Wrap(err, slackerror.ErrInvalidManifest). - WithMessage("Failed to fetch manifest for app %s", appID) - } - return manifest, nil -} - -// WriteManifestToProject writes the fetched manifest JSON to the project directory. -func WriteManifestToProject(fs afero.Fs, projectPath string, manifest types.SlackYaml) error { - manifestData, err := json.MarshalIndent(manifest.AppManifest, "", " ") - if err != nil { - return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). - WithMessage("Failed to serialize app manifest") - } - - manifestPath := filepath.Join(projectPath, "manifest.json") - if err := afero.WriteFile(fs, manifestPath, append(manifestData, '\n'), 0644); err != nil { - return slackerror.Wrap(err, slackerror.ErrProjectFileUpdate). - WithMessage("Failed to write manifest to project") - } - return nil -} - -// SaveAppToProject writes the linked app to the project's apps JSON file, -// checking for conflicts before saving unless --force is set. -func SaveAppToProject(ctx context.Context, clients *shared.ClientFactory, app types.App) error { - deploy, err := clients.AppClient().GetDeployed(ctx, app.TeamID) - if err != nil { - return err - } - local, err := clients.AppClient().GetLocal(ctx, app.TeamID) - if err != nil { - return err - } - switch app.IsDev { - case true: - if clients.Config.ForceFlag || (local.IsNew() && deploy.AppID != app.AppID) { - return clients.AppClient().SaveLocal(ctx, app) - } - case false: - if clients.Config.ForceFlag || (deploy.IsNew() && local.AppID != app.AppID) { - return clients.AppClient().SaveDeployed(ctx, app) - } - } - return slackerror.New(slackerror.ErrAppFound). - WithMessage("A saved app was found and cannot be overwritten"). - WithRemediation("Remove the app from this project or try again with %s", style.Bold("--force")) -} diff --git a/internal/pkg/apps/link_test.go b/internal/pkg/apps/link_test.go deleted file mode 100644 index d2967557..00000000 --- a/internal/pkg/apps/link_test.go +++ /dev/null @@ -1,318 +0,0 @@ -// 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 apps - -import ( - "encoding/json" - "testing" - - "github.com/slackapi/slack-cli/internal/api" - "github.com/slackapi/slack-cli/internal/app" - "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -func Test_FetchRemoteManifest(t *testing.T) { - tests := map[string]struct { - manifest types.SlackYaml - mockErr error - expectErr bool - }{ - "returns manifest on success": { - manifest: types.SlackYaml{ - AppManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{Name: "Test App"}, - }, - }, - }, - "preserves original error on failure": { - mockErr: assert.AnError, - expectErr: true, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - manifestMock := clients.AppClient().Manifest.(*app.ManifestMockObject) - manifestMock.On("GetManifestRemote", mock.Anything, "token", "A123"). - Return(tc.manifest, tc.mockErr) - - result, err := FetchRemoteManifest(t.Context(), clients, "token", "A123") - if tc.expectErr { - require.Error(t, err) - assert.Contains(t, err.Error(), assert.AnError.Error()) - } else { - require.NoError(t, err) - assert.Equal(t, "Test App", result.DisplayInformation.Name) - } - }) - } -} - -func Test_WriteManifestToProject(t *testing.T) { - fs := afero.NewMemMapFs() - _ = fs.MkdirAll("test-project", 0755) - - manifest := types.SlackYaml{ - AppManifest: types.AppManifest{ - DisplayInformation: types.DisplayInformation{ - Name: "My App", - }, - Settings: &types.AppSettings{ - FunctionRuntime: types.Remote, - }, - }, - } - - err := WriteManifestToProject(fs, "test-project", manifest) - require.NoError(t, err) - - data, err := afero.ReadFile(fs, "test-project/manifest.json") - require.NoError(t, err) - - var result map[string]any - require.NoError(t, json.Unmarshal(data, &result)) - - displayInfo := result["display_information"].(map[string]any) - assert.Equal(t, "My App", displayInfo["name"]) -} - -func Test_SaveAppToProject(t *testing.T) { - tests := map[string]struct { - app types.App - existingDeploy types.App - existingLocal types.App - forceFlag bool - expectSave string - expectErr bool - }{ - "saves local dev app when no existing apps": { - app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, - existingDeploy: types.NewApp(), - existingLocal: types.NewApp(), - expectSave: "local", - }, - "saves deployed app when no existing apps": { - app: types.App{AppID: "A1", TeamID: "T1", IsDev: false}, - existingDeploy: types.NewApp(), - existingLocal: types.NewApp(), - expectSave: "deployed", - }, - "returns error when local app exists and same app in deploy": { - app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, - existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, - existingLocal: types.App{AppID: "A1", TeamID: "T1"}, - expectErr: true, - }, - "saves local with force when conflict exists": { - app: types.App{AppID: "A1", TeamID: "T1", IsDev: true}, - existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, - existingLocal: types.App{AppID: "A1", TeamID: "T1"}, - forceFlag: true, - expectSave: "local", - }, - "saves deployed with force when conflict exists": { - app: types.App{AppID: "A1", TeamID: "T1", IsDev: false}, - existingDeploy: types.App{AppID: "A1", TeamID: "T1"}, - existingLocal: types.App{AppID: "A1", TeamID: "T1"}, - forceFlag: true, - expectSave: "deployed", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - clients.Config.ForceFlag = tc.forceFlag - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, tc.app.TeamID).Return(tc.existingDeploy, nil) - appClientMock.On("GetLocal", mock.Anything, tc.app.TeamID).Return(tc.existingLocal, nil) - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - appClientMock.On("SaveDeployed", mock.Anything, mock.Anything).Return(nil) - clients.AppClient().AppClientInterface = appClientMock - - err := SaveAppToProject(t.Context(), clients, tc.app) - if tc.expectErr { - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be overwritten") - } else { - require.NoError(t, err) - switch tc.expectSave { - case "local": - appClientMock.AssertCalled(t, "SaveLocal", mock.Anything, mock.Anything) - case "deployed": - appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) - } - } - }) - } -} - -func Test_ResolveAuthForApp(t *testing.T) { - tests := map[string]struct { - setupAuth func(*shared.ClientsMock) - appID string - expectErr bool - expectTeamID string - }{ - "returns first auth that has access": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, - {Token: "token-b", TeamID: "T002", TeamDomain: "team-b"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). - Return(api.GetAppStatusResult{}, assert.AnError) - cm.API.On("GetAppStatus", mock.Anything, "token-b", []string{"A111"}, "T002"). - Return(api.GetAppStatusResult{}, nil) - }, - appID: "A111", - expectTeamID: "T002", - }, - "returns error when no auths": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) - }, - appID: "A111", - expectErr: true, - }, - "returns error when no auth has access": { - setupAuth: func(cm *shared.ClientsMock) { - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "token-a", TeamID: "T001", TeamDomain: "team-a"}, - }, nil) - cm.API.On("GetAppStatus", mock.Anything, "token-a", []string{"A111"}, "T001"). - Return(api.GetAppStatusResult{}, assert.AnError) - }, - appID: "A111", - expectErr: true, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - tc.setupAuth(cm) - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - auth, err := ResolveAuthForApp(t.Context(), clients, tc.appID) - if tc.expectErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tc.expectTeamID, auth.TeamID) - } - }) - } -} - -func Test_LinkAppToProject(t *testing.T) { - tests := map[string]struct { - runtime types.FunctionRuntime - environment string - expectDev bool - }{ - "infers local for remote runtime": { - runtime: types.Remote, - expectDev: true, - }, - "infers local for local runtime": { - runtime: types.LocallyRun, - expectDev: true, - }, - "infers deployed for slack-hosted runtime": { - runtime: types.SlackHosted, - expectDev: false, - }, - "environment flag deployed overrides manifest": { - runtime: types.Remote, - environment: "deployed", - expectDev: false, - }, - "environment flag local overrides manifest": { - runtime: types.SlackHosted, - environment: "local", - expectDev: true, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - cm := shared.NewClientsMock() - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - if tc.expectDev { - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - } else { - appClientMock.On("SaveDeployed", mock.Anything, mock.MatchedBy(func(a types.App) bool { - return a.AppID == "A999" && a.TeamID == "T123" && !a.IsDev - })).Return(nil) - } - clients.AppClient().AppClientInterface = appClientMock - - auth := types.SlackAuth{ - Token: "xoxp-token", - TeamID: "T123", - TeamDomain: "my-team", - UserID: "U456", - } - manifest := types.SlackYaml{ - AppManifest: types.AppManifest{ - Settings: &types.AppSettings{ - FunctionRuntime: tc.runtime, - }, - }, - } - - err := LinkAppToProject(t.Context(), clients, auth, "A999", manifest, tc.environment) - require.NoError(t, err) - - if tc.expectDev { - appClientMock.AssertCalled(t, "SaveLocal", mock.Anything, mock.Anything) - } else { - appClientMock.AssertCalled(t, "SaveDeployed", mock.Anything, mock.Anything) - } - }) - } -} - -func Test_LinkAppToProject_invalidEnvironment(t *testing.T) { - cm := shared.NewClientsMock() - cm.AddDefaultMocks() - clients := shared.NewClientFactory(cm.MockClientFactory()) - - auth := types.SlackAuth{Token: "token", TeamID: "T1"} - manifest := types.SlackYaml{} - - err := LinkAppToProject(t.Context(), clients, auth, "A1", manifest, "invalid") - require.Error(t, err) - assert.Contains(t, err.Error(), "local") -} From 315c9bb97c0976fbbcf0c7f5c78118a6865d3b24 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 8 Jun 2026 11:50:40 -0400 Subject: [PATCH 4/9] simplify redundant tests --- cmd/project/create_test.go | 47 -------------------------------------- 1 file changed, 47 deletions(-) diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 9265882b..d2fb871d 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,20 +16,14 @@ package project import ( "context" - "os" - "path/filepath" "testing" - "github.com/slackapi/slack-cli/internal/api" - "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" "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" "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" @@ -886,47 +880,6 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, - "app flag with template creates project then runs link": { - 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) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - - // LinkExistingApp calls PromptTeamSlackAuth which calls Auths - cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-test-token", TeamID: "T123", TeamDomain: "test-team", UserID: "U123"}, - }, nil) - // LinkExistingApp validates app access - cm.API.On("GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123"). - Return(api.GetAppStatusResult{}, nil) - - // LinkExistingApp calls saveAppToJSON - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployed", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("GetLocal", mock.Anything, "T123").Return(types.NewApp(), nil) - appClientMock.On("SaveLocal", mock.Anything, mock.Anything).Return(nil) - cf.AppClient().AppClientInterface = appClientMock - - // Create project directory on real filesystem for os.Chdir - tmpDir := t.TempDir() - projectDir := filepath.Join(tmpDir, "my-app") - require.NoError(t, os.MkdirAll(projectDir, 0755)) - - // Set up mock Os.Getwd to return the project dir (used by ProjectConfig) - cm.Os.On("Getwd").Return(projectDir, nil) - // Create .slack/hooks.json on mock filesystem so GetProjectDirPath succeeds - require.NoError(t, cm.Fs.MkdirAll(filepath.Join(projectDir, ".slack"), 0755)) - require.NoError(t, afero.WriteFile(cm.Fs, filepath.Join(projectDir, ".slack", "hooks.json"), []byte("{}"), 0644)) - - createClientMock = new(CreateClientMock) - createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) - CreateFunc = createClientMock.Create - }, - ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) - cm.API.AssertCalled(t, "GetAppStatus", mock.Anything, "xoxp-test-token", []string{"A0123456789"}, "T123") - }, - }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewCreateCommand(cf) }) From 60d222ab294742d65af94aabf3b2a41badc2d74a Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 9 Jun 2026 14:17:36 -0400 Subject: [PATCH 5/9] feat: validate --environment flag values in create command Co-Authored-By: Claude --- cmd/project/create.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index 6fd0cc74..53be9afe 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -35,12 +35,12 @@ import ( ) // Flags -var createTemplateURLFlag string -var createGitBranchFlag string var createAppNameFlag string +var createEnvironmentFlag string +var createGitBranchFlag string var createListFlag bool var createSubdirFlag string -var createEnvironmentFlag string +var createTemplateURLFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client @@ -71,7 +71,7 @@ name your app 'agent' (not create an AI Agent), use the --name flag instead.`, {Command: "create my-project -t slack-samples/deno-hello-world", Meaning: "Start a new project from a specific template"}, {Command: "create --name my-project", Meaning: "Create a project named 'my-project'"}, {Command: "create my-project -t org/monorepo --subdir apps/my-app", Meaning: "Create from a subdirectory of a template"}, - {Command: "create my-project -t slack-samples/bolt-js-starter-template --app A0123456789", Meaning: "Create from template and link to an existing app"}, + {Command: "create my-project -t slack-samples/bolt-js-starter-template --app A0123456789 --environment local", Meaning: "Create from template and link to an existing app"}, }), Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -140,10 +140,16 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --app flag requires the --template flag when used with create") } - // --environment requires --app - if cmd.Flags().Changed("environment") && !appFlagProvided { - return slackerror.New(slackerror.ErrMismatchedFlags). - WithMessage("The --environment flag requires the --app flag when used with create") + // --environment requires --app and must be "local" or "deployed" + if cmd.Flags().Changed("environment") { + if !appFlagProvided { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --environment flag requires the --app flag when used with create") + } + if !types.IsAppFlagEnvironment(createEnvironmentFlag) { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("The --environment flag must be either 'local' or 'deployed'") + } } // Collect the template URL or select a starting template From 8ef2b29a29437ec21a592f00582e8e1b84394e75 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 9 Jun 2026 14:42:16 -0400 Subject: [PATCH 6/9] feat: add happy path tests for --app flag and make LinkFunc mockable --- cmd/project/create.go | 3 +- cmd/project/create_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index 53be9afe..dc501e65 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -45,6 +45,7 @@ var createTemplateURLFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client var CreateFunc = create.Create +var LinkFunc = app.LinkExistingApp // promptObject describes the Github app template type promptObject struct { @@ -217,7 +218,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] if err := os.Chdir(absProjectPath); err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - linkErr := app.LinkExistingApp(ctx, clients, &types.App{}, false) + linkErr := LinkFunc(ctx, clients, &types.App{}, false) _ = os.Chdir(originalDir) if linkErr != nil { return linkErr diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index d2fb871d..a3d4d332 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -22,6 +22,7 @@ import ( "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" @@ -880,6 +881,64 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, + "invalid environment flag returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "invalid"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --environment flag must be either 'local' or 'deployed'"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with template creates project then calls link": { + 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) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + linkCalled := false + LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { + linkCalled = true + assert.False(t, shouldConfirm) + return nil + } + t.Cleanup(func() { + assert.True(t, linkCalled, "LinkFunc should have been called") + }) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag without environment still calls link": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + linkCalled := false + LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { + linkCalled = true + return nil + } + t.Cleanup(func() { + assert.True(t, linkCalled, "LinkFunc should have been called") + }) + }, + 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) }) From c9f5bbf6706c4b3aab17f2b5bcdb6e43f07e9877 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 9 Jun 2026 17:23:11 -0700 Subject: [PATCH 7/9] fix: validate --app value as an app ID in create The --app flag silently accepted environment-style values (local, deployed) and lowercase typos because the gate combined non-empty and IsAppID into a single boolean. The project would scaffold and the link step would be skipped without any error. Validate --app explicitly: when it is set but not an app ID, return ErrInvalidAppFlag immediately so users see a clear error rather than a silent skip. --- cmd/project/create.go | 8 +++++++- cmd/project/create_test.go | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index dc501e65..509d4fda 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -134,8 +134,14 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } + // --app must be an app ID when used with create + appFlagProvided := clients.Config.AppFlag != "" + if appFlagProvided && !types.IsAppID(clients.Config.AppFlag) { + return slackerror.New(slackerror.ErrInvalidAppFlag). + WithMessage("The --app flag requires an app ID when used with create") + } + // --app requires --template - appFlagProvided := clients.Config.AppFlag != "" && types.IsAppID(clients.Config.AppFlag) if appFlagProvided && !templateFlagProvided { return slackerror.New(slackerror.ErrMismatchedFlags). WithMessage("The --app flag requires the --template flag when used with create") diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index a3d4d332..85db564e 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -870,6 +870,28 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, + "app flag with environment-style value returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires an app ID when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "app flag with lowercase id returns error": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "a0123456789"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + CreateFunc = createClientMock.Create + }, + ExpectedErrorStrings: []string{"The --app flag requires an app ID when used with create"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + }, + }, "environment flag without app flag returns error": { CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--environment", "deployed"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { From 7564232c8c181d66f14c989299dfe372f08b861d Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 9 Jun 2026 17:23:45 -0700 Subject: [PATCH 8/9] fix: restore working directory safely after linking in create Three issues in the cwd dance after creating a project with --app: 1. The Getwd error was discarded, so a Getwd failure left originalDir empty and silently broke the restore. 2. The os.Chdir restore was inline, not deferred, so a panic in LinkExistingApp would skip it and leave the user's process in the project subdir. 3. The os.Chdir restore error was discarded. Capture the Getwd error and return it, defer the restore so it runs on panic, and stop collapsing linkErr through a temporary variable. Mirrors the pattern already used in internal/pkg/create/create.go. --- cmd/project/create.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index 509d4fda..c2416ffd 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -220,14 +220,18 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] if err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - originalDir, _ := clients.Os.Getwd() + originalDir, err := clients.Os.Getwd() + if err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } if err := os.Chdir(absProjectPath); err != nil { return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) } - linkErr := LinkFunc(ctx, clients, &types.App{}, false) - _ = os.Chdir(originalDir) - if linkErr != nil { - return linkErr + defer func() { + _ = os.Chdir(originalDir) + }() + if err := LinkFunc(ctx, clients, &types.App{}, false); err != nil { + return err } } From 6533815ebf6575b86cc62838d2a6005494e53ac3 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 9 Jun 2026 17:27:37 -0700 Subject: [PATCH 9/9] refactor: remove LinkFunc indirection from create tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the var LinkFunc = app.LinkExistingApp test seam and call the real function directly. The two happy-path tests now mock LinkExistingApp's underlying dependencies (Auth.Auths, IO prompts, API.GetAppStatus, the project config and manifest) so they verify the app is actually persisted via cm.AppClient.GetDeployed/GetLocal — mirroring the test pattern in cmd/app/link_test.go. This also resolves the leak where the swapped LinkFunc was not restored between tests. --- cmd/project/create.go | 3 +- cmd/project/create_test.go | 99 ++++++++++++++++++++++++++++---------- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/cmd/project/create.go b/cmd/project/create.go index c2416ffd..1adff451 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -45,7 +45,6 @@ var createTemplateURLFlag string // Handle to client's create function used for testing // TODO - Find best practice, such as using an Interface and Struct to create a client var CreateFunc = create.Create -var LinkFunc = app.LinkExistingApp // promptObject describes the Github app template type promptObject struct { @@ -230,7 +229,7 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] defer func() { _ = os.Chdir(originalDir) }() - if err := LinkFunc(ctx, clients, &types.App{}, false); err != nil { + if err := app.LinkExistingApp(ctx, clients, &types.App{}, false); err != nil { return err } } diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index 85db564e..e0c4b7b4 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -16,13 +16,17 @@ package project import ( "context" + "fmt" "testing" + "github.com/slackapi/slack-cli/internal/api" + "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" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackdeps" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" @@ -914,54 +918,97 @@ func TestCreateCommand_AppFlag(t *testing.T) { createClientMock.AssertNotCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) }, }, - "app flag with template creates project then calls link": { - CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + "app flag with template creates project and links a deployed app": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) CreateFunc = createClientMock.Create - linkCalled := false - LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { - linkCalled = true - assert.False(t, shouldConfirm) - return nil - } - t.Cleanup(func() { - assert.True(t, linkCalled, "LinkFunc should have been called") - }) + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocks(t, ctx, cm, cf) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "deployed"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetDeployed(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID) + assert.False(t, saved.IsDev) }, }, - "app flag without environment still calls link": { + "app flag without environment links a local app via prompt": { CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() - createClientMock = new(CreateClientMock) createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) CreateFunc = createClientMock.Create - linkCalled := false - LinkFunc = func(ctx context.Context, clients *shared.ClientFactory, a *types.App, shouldConfirm bool) error { - linkCalled = true - return nil - } - t.Cleanup(func() { - assert.True(t, linkCalled, "LinkFunc should have been called") - }) + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocks(t, ctx, cm, cf) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", 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, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + assert.Equal(t, mockCreateLinkAuth.TeamID, saved.TeamID) + assert.True(t, saved.IsDev) }, }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewCreateCommand(cf) }) } + +var mockCreateLinkAuth = types.SlackAuth{ + Token: "xoxp-example", + TeamDomain: "team1", + TeamID: "T001", + EnterpriseID: "E001", + 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) + + if _, err := config.CreateProjectConfigDir(ctx, cm.Fs, projectDirPath); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to create the project config directory: %s", err)) + } + if _, err := config.CreateProjectHooksJSONFile(cm.Fs, projectDirPath, []byte("{}")); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to create the hooks file: %s", err)) + } + if err := config.SetManifestSource(ctx, cm.Fs, cm.Os, config.ManifestSourceRemote); err != nil { + require.FailNow(t, fmt.Sprintf("Failed to set the manifest source: %s", err)) + } + + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything). + Return(types.SlackYaml{}, nil) + cf.AppClient().Manifest = manifestMock +}