diff --git a/cmd/project/create.go b/cmd/project/create.go index 549b3e0b..c7089f03 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -18,13 +18,16 @@ import ( "context" "fmt" "math/rand" + "os" "path/filepath" "strings" "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" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" @@ -37,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 @@ -67,6 +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"}, }), Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { @@ -81,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 } @@ -127,6 +133,36 @@ 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") + } + + // --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 := 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 { @@ -147,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, @@ -183,6 +225,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 := 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) + _ = os.Chdir(originalDir) + if linkErr != nil { + return linkErr + } + } + printCreateSuccess(ctx, clients, appDirPath) return nil } 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/internal/pkg/apps/link.go b/internal/pkg/apps/link.go new file mode 100644 index 00000000..69d6ebd4 --- /dev/null +++ b/internal/pkg/apps/link.go @@ -0,0 +1,157 @@ +// 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 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") +}