diff --git a/cmd/app/link.go b/cmd/app/link.go index 044f56c3..6ca77347 100644 --- a/cmd/app/link.go +++ b/cmd/app/link.go @@ -97,7 +97,7 @@ func LinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, app *ty // Add empty line between executed command and first output clients.IO.PrintInfo(ctx, false, "") - err = LinkExistingApp(ctx, clients, app, false) + _, err = LinkExistingApp(ctx, clients, app, false) if err != nil { return err } @@ -130,7 +130,7 @@ func LinkAppHeaderSection(ctx context.Context, clients *shared.ClientFactory, sh // When shouldConfirm is true, a confirmation prompt will ask the user if they want to // link an existing app and additional information is included in the header. // The shouldConfirm option is encouraged for third-party callers. -func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *types.App, shouldConfirm bool) (err error) { +func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *types.App, shouldConfirm bool) (_ *types.SlackAuth, err error) { // Header section LinkAppHeaderSection(ctx, clients, shouldConfirm) @@ -139,21 +139,21 @@ func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *ty proceed, err := clients.IO.ConfirmPrompt(ctx, LinkAppConfirmPromptText, true) if err != nil { clients.IO.PrintDebug(ctx, "Error prompting to add an existing app: %s", err) - return err + return nil, err } // Add newline to match the trailing newline inserted from the footer section clients.IO.PrintInfo(ctx, false, "") if !proceed { - return nil + return nil, nil } } // App Manifest section manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) if err != nil { - return err + return nil, err } configPath := filepath.Join(config.ProjectConfigDirName, config.ProjectConfigJSONFilename) @@ -170,26 +170,26 @@ func LinkExistingApp(ctx context.Context, clients *shared.ClientFactory, app *ty var auth *types.SlackAuth *app, auth, err = promptExistingApp(ctx, clients) if err != nil { - return err + return nil, err } appIDs := []string{app.AppID} _, err = clients.API().GetAppStatus(ctx, auth.Token, appIDs, app.TeamID) if err != nil { - return err + return nil, err } // Save the app to the project err = saveAppToJSON(ctx, clients, *app) if err != nil { clients.IO.PrintDebug(ctx, "Error saving app to file when linking existing app: %s", err) - return err + return nil, err } // Footer section LinkAppFooterSection(ctx, clients, app) - return nil + return auth, nil } // LinkAppFooterSection displays the details of app that was added to the project. diff --git a/cmd/project/create.go b/cmd/project/create.go index 549b3e0b..7991b6fd 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -16,18 +16,23 @@ package project import ( "context" + "encoding/json" "fmt" "math/rand" + "os" "path/filepath" "strings" "time" + "github.com/slackapi/slack-cli/cmd/app" "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" + "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -37,6 +42,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 +73,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 +88,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 +135,19 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] WithMessage("The --subdir flag requires the --template flag") } + // --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") + } + + // --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") + } + // Collect the template URL or select a starting template template, err := promptTemplateSelection(cmd, clients, categoryShortcut) if err != nil { @@ -183,6 +204,37 @@ 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) + } + originalDir, _ := clients.Os.Getwd() + if err := os.Chdir(absProjectPath); err != nil { + return slackerror.Wrap(err, slackerror.ErrAppDirectoryAccess) + } + + linkedApp := &types.App{} + auth, linkErr := app.LinkExistingApp(ctx, clients, linkedApp, false) + _ = os.Chdir(originalDir) + if linkErr != nil { + return linkErr + } + + if auth != nil && linkedApp.AppID != "" { + fetchErr := fetchAndWriteRemoteManifest(ctx, clients, auth.Token, linkedApp.AppID, absProjectPath) + if fetchErr != nil { + clients.IO.PrintWarning(ctx, "%s", style.Sectionf(style.TextSection{ + Text: "Could not fetch the remote app manifest", + Secondary: []string{ + fetchErr.Error(), + "The template manifest was kept unchanged", + }, + })) + } + } + } + printCreateSuccess(ctx, clients, appDirPath) return nil } @@ -238,6 +290,21 @@ func printCreateSuccess(ctx context.Context, clients *shared.ClientFactory, appP clients.IO.PrintTrace(ctx, slacktrace.CreateSuccess) } +// fetchAndWriteRemoteManifest fetches the app manifest from remote settings and writes it to the project. +func fetchAndWriteRemoteManifest(ctx context.Context, clients *shared.ClientFactory, token, appID, projectPath string) error { + slackYaml, err := clients.AppClient().Manifest.GetManifestRemote(ctx, token, appID) + if err != nil { + return err + } + data, err := json.MarshalIndent(slackYaml.AppManifest, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + manifestPath := filepath.Join(projectPath, "manifest.json") + return afero.WriteFile(clients.Fs, manifestPath, data, 0644) +} + // generateRandomAppName will create a random app name based on two words and a number func generateRandomAppName() string { rand.New(rand.NewSource(time.Now().UnixNano())) diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index d2fa65f1..b89d79f2 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -18,12 +18,16 @@ import ( "context" "testing" + "github.com/slackapi/slack-cli/internal/api" + internalApp "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 +857,130 @@ 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) + }, + }, + "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) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func TestCreateCommand_AppFlag_FetchesRemoteManifest(t *testing.T) { + var createClientMock *CreateClientMock + + mockAuth := types.SlackAuth{ + Token: "xoxp-test-token", + TeamDomain: "test-team", + TeamID: "T001", + UserID: "U001", + } + mockManifest := types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "My Remote App", + Description: "An app from remote settings", + }, + }, + } + + setupAppFlagMocks := func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) string { + projectDir := t.TempDir() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + + // Getwd is called to save original dir, then GetProjectDirPath calls it inside LinkExistingApp. + // After os.Chdir, the second call should return the project dir. + cm.Os.On("Getwd").Return(projectDir, nil) + + // Set up .slack/hooks.json so GetProjectDirPath validates the project + err := cm.Fs.MkdirAll(projectDir+"/.slack", 0755) + require.NoError(t, err) + err = afero.WriteFile(cm.Fs, projectDir+"/.slack/hooks.json", []byte("{}"), 0644) + require.NoError(t, err) + + // Template selection returns via flag + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil) + + // Link prompts + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockAuth}, nil) + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Index: 0, Option: mockAuth.TeamDomain}, 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, mockAuth.Token, []string{"A0123456789"}, mockAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + + return projectDir + } + + var projectDir string + + testutil.TableTestCommand(t, testutil.CommandTests{ + "fetches remote manifest after linking app": { + 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) { + projectDir = setupAppFlagMocks(t, ctx, cm, cf) + + manifestMock := &internalApp.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mockAuth.Token, "A0123456789"). + Return(mockManifest, nil) + cf.AppClient().Manifest = manifestMock + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + + manifestData, err := afero.ReadFile(cm.Fs, projectDir+"/manifest.json") + require.NoError(t, err) + assert.Contains(t, string(manifestData), `"name": "My Remote App"`) + assert.Contains(t, string(manifestData), `"description": "An app from remote settings"`) + }, + }, + "warns on manifest fetch failure": { + 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) { + projectDir = setupAppFlagMocks(t, ctx, cm, cf) + + manifestMock := &internalApp.ManifestMockObject{} + manifestMock.On("GetManifestRemote", mock.Anything, mockAuth.Token, "A0123456789"). + Return(types.SlackYaml{}, slackerror.New("network error")) + cf.AppClient().Manifest = manifestMock + }, + ExpectedStdoutOutputs: []string{ + "Could not fetch the remote app manifest", + "The template manifest was kept unchanged", + }, + 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/init.go b/cmd/project/init.go index 9f9d5f90..dff035ff 100644 --- a/cmd/project/init.go +++ b/cmd/project/init.go @@ -110,7 +110,7 @@ func projectInitCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, a _ = create.InstallProjectDependencies(ctx, clients, projectDirPath) // Add an existing app to the project - err = app.LinkExistingApp(ctx, clients, &types.App{}, true) + _, err = app.LinkExistingApp(ctx, clients, &types.App{}, true) if err != nil { // Display the error but continue to init clients.IO.PrintError(ctx, "%s", err.Error())