Skip to content
18 changes: 9 additions & 9 deletions cmd/app/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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.
Expand Down
67 changes: 67 additions & 0 deletions cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()))
Expand Down
131 changes: 131 additions & 0 deletions cmd/project/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
}
2 changes: 1 addition & 1 deletion cmd/project/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading