Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0d8bfd3
feat: add --app flag support to slack create for linking existing apps
srtaalej May 28, 2026
da2eb75
address pr feedback
srtaalej Jun 1, 2026
e57f7d5
Merge branch 'main' into ale-app-id-flag
srtaalej Jun 2, 2026
aceb7a4
Merge branch 'main' into ale-app-id-flag
srtaalej Jun 2, 2026
906346a
simplify app creation with app link cmd
srtaalej Jun 8, 2026
8128d5e
Merge branch 'main' into ale-app-id-flag
srtaalej Jun 8, 2026
315c9bb
simplify redundant tests
srtaalej Jun 8, 2026
3177aa1
feat: fetch remote manifest after create with app ID
srtaalej Jun 9, 2026
6d6749a
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 9, 2026
42782a3
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 12, 2026
3d642ca
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 16, 2026
f984c33
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 18, 2026
3396d78
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 25, 2026
bdbf756
refactor: move confirmation prompt out of LinkExistingApp into callers
srtaalej Jun 30, 2026
1538faf
undo changes to link command
srtaalej Jun 30, 2026
040a3c8
refactor: move fetchAndWriteRemoteManifest to internal/manifest/expor…
srtaalej Jun 30, 2026
1092da0
fix: avoid escaping forward slashes in exported manifest JSON
srtaalej Jun 30, 2026
aecb626
fix: save manifest hash after fetching remote manifest on create
srtaalej Jun 30, 2026
c289a09
refactor: remove auth return from LinkExistingApp, use AuthWithTeamID…
srtaalej Jun 30, 2026
19c2fac
chore: remove unnecessary import alias in create_test.go
srtaalej Jun 30, 2026
2283fbc
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jun 30, 2026
236702a
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jul 1, 2026
8639a29
fix: correct indentation from merge resolution
srtaalej Jul 1, 2026
2939882
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jul 2, 2026
19cfb59
Merge branch 'main' into ale-fetch-remote-manifest
srtaalej Jul 6, 2026
22a96a9
refactor: reuse goutils.JSONMarshalUnescapedIndent in manifest export
srtaalej Jul 7, 2026
43e5d3d
refactor: rename to SetManifestLocal and add success output
srtaalej Jul 8, 2026
9ad0d79
fix: return error on manifest fetch failure instead of warning
srtaalej Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cmd/project/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (

"github.com/slackapi/slack-cli/cmd/app"
"github.com/slackapi/slack-cli/internal/iostreams"
"github.com/slackapi/slack-cli/internal/manifest"
"github.com/slackapi/slack-cli/internal/pkg/create"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
Expand Down Expand Up @@ -229,10 +230,27 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []
defer func() {
_ = os.Chdir(originalDir)
}()

linkedApp := &types.App{}
if err := app.LinkExistingApp(ctx, clients, linkedApp); err != nil {
return err
}

if linkedApp.AppID != "" {
auth, err := clients.Auth().AuthWithTeamID(ctx, linkedApp.TeamID)
if err != nil {
return err
}
if err := manifest.SetManifestLocal(ctx, clients, auth.Token, linkedApp.AppID, absProjectPath); err != nil {
return slackerror.Wrap(err, "Could not fetch the remote app manifest").
WithRemediation("Run %s to retrieve your app manifest manually", style.Commandf("manifest info --source remote", true))
}
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "pencil2",
Text: "Manifest",
Secondary: []string{"Written to manifest.json from remote app settings"},
}))
}
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
Emoji: "house",
Text: "App",
Expand Down
95 changes: 93 additions & 2 deletions cmd/project/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/slackapi/slack-cli/internal/slackdeps"
"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 @@ -983,6 +984,95 @@ func TestCreateCommand_AppFlag(t *testing.T) {
})
}

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

cm.Os.On("Getwd").Return(projectDir, nil)

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)

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)

cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockAuth}, nil)
cm.Auth.On("AuthWithTeamID", mock.Anything, mock.Anything).Return(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 := &app.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"`)
},
},
"returns error 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 := &app.ManifestMockObject{}
manifestMock.On("GetManifestRemote", mock.Anything, mockAuth.Token, "A0123456789").
Return(types.SlackYaml{}, slackerror.New("network error"))
cf.AppClient().Manifest = manifestMock
},
ExpectedErrorStrings: []string{"network error"},
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)
})
}

var mockCreateLinkAuth = types.SlackAuth{
Token: "xoxp-example",
TeamDomain: "team1",
Expand All @@ -991,11 +1081,10 @@ var mockCreateLinkAuth = types.SlackAuth{
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)
cm.Auth.On("AuthWithTeamID", mock.Anything, mock.Anything).Return(mockCreateLinkAuth, 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))
Expand All @@ -1010,5 +1099,7 @@ func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsM
manifestMock := &app.ManifestMockObject{}
manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).
Return(types.SlackYaml{}, nil)
manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(types.SlackYaml{}, nil)
cf.AppClient().Manifest = manifestMock
}
45 changes: 45 additions & 0 deletions internal/manifest/export.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 todo: Let's add unit tests alongside internal packages for more strict checks!

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 manifest

import (
"context"
"path/filepath"

"github.com/slackapi/slack-cli/internal/goutils"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/spf13/afero"
)

// SetManifestLocal fetches the app manifest from remote settings and writes it to the project.
func SetManifestLocal(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
}
manifestJSON, err := goutils.JSONMarshalUnescapedIndent(slackYaml.AppManifest)
if err != nil {
return err
}
manifestPath := filepath.Join(projectPath, "manifest.json")
if err := afero.WriteFile(clients.Fs, manifestPath, []byte(manifestJSON), 0644); err != nil {
return err
}
hash, err := clients.Config.ProjectConfig.Cache().NewManifestHash(ctx, slackYaml.AppManifest)
if err != nil {
return err
}
return clients.Config.ProjectConfig.Cache().SetManifestHash(ctx, appID, hash)
Comment on lines +40 to +44

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏆 praise: Thanks for including this!

🦔 thought: I'm now curious if this would be better as part of the app link command? We might pull the manifest to write a hash when linking a specific app which can improve the link experience outside of create command?

}
139 changes: 139 additions & 0 deletions internal/manifest/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// 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 manifest

import (
"testing"

"github.com/slackapi/slack-cli/internal/app"
"github.com/slackapi/slack-cli/internal/cache"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/goutils"
"github.com/slackapi/slack-cli/internal/hooks"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func Test_SetManifestLocal(t *testing.T) {
testManifest := types.SlackYaml{
AppManifest: types.AppManifest{
DisplayInformation: types.DisplayInformation{Name: "Test App"},
},
}

tests := map[string]struct {
setupMocks func(*shared.ClientsMock, *shared.ClientFactory)
expectError bool
assertFile bool
}{
"writes manifest and saves hash on success": {
setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) {
manifestMock := &app.ManifestMockObject{}
manifestMock.On("GetManifestRemote", mock.Anything, "xoxp-token", "A001").
Return(testManifest, nil)
clients.AppClient().Manifest = manifestMock

cc := cache.NewCacheMock()
cc.On("NewManifestHash", mock.Anything, testManifest.AppManifest).
Return(cache.Hash("abc123"), nil)
cc.On("SetManifestHash", mock.Anything, "A001", cache.Hash("abc123")).
Return(nil)

proj := config.NewProjectConfigMock()
proj.On("Cache").Return(cc)
clients.Config.ProjectConfig = proj
},
expectError: false,
assertFile: true,
},
"returns error when GetManifestRemote fails": {
setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) {
manifestMock := &app.ManifestMockObject{}
manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(types.SlackYaml{}, slackerror.New("api error"))
clients.AppClient().Manifest = manifestMock
},
expectError: true,
assertFile: false,
},
"returns error when file write fails": {
setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) {
manifestMock := &app.ManifestMockObject{}
manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(testManifest, nil)
clients.AppClient().Manifest = manifestMock

clients.Fs = afero.NewReadOnlyFs(&afero.MemMapFs{})
},
expectError: true,
assertFile: false,
},
"returns error when SetManifestHash fails": {
setupMocks: func(clientsMock *shared.ClientsMock, clients *shared.ClientFactory) {
manifestMock := &app.ManifestMockObject{}
manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).
Return(testManifest, nil)
clients.AppClient().Manifest = manifestMock

cc := cache.NewCacheMock()
cc.On("NewManifestHash", mock.Anything, mock.Anything).
Return(cache.Hash("abc123"), nil)
cc.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything).
Return(slackerror.New("cache write error"))

proj := config.NewProjectConfigMock()
proj.On("Cache").Return(cc)
clients.Config.ProjectConfig = proj
},
expectError: true,
assertFile: true,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()
clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(c *shared.ClientFactory) {
c.SDKConfig = hooks.NewSDKConfigMock()
})

tc.setupMocks(clientsMock, clients)

err := SetManifestLocal(ctx, clients, "xoxp-token", "A001", "/project")

if tc.expectError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}

if tc.assertFile {
content, readErr := afero.ReadFile(clients.Fs, "/project/manifest.json")
require.NoError(t, readErr)

expected, _ := goutils.JSONMarshalUnescapedIndent(testManifest.AppManifest)
assert.Equal(t, expected, string(content))
}
})
}
}
2 changes: 2 additions & 0 deletions internal/style/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ func Emoji(alias string) string {
padding = " "
case "beach_with_umbrella":
padding = " "
case "pencil2":
padding = " "
}

return emoji.Sprint(":"+alias+":") + padding
Expand Down
Loading