From 4d6a19efc30f53560c767af70a4db1f703c814f4 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Fri, 3 Apr 2026 14:41:21 -0400 Subject: [PATCH 1/6] feat: set icon method for Bolt apps --- internal/api/app.go | 1 + internal/api/icon.go | 16 +++++++++-- internal/api/icon_mock.go | 7 ++++- internal/api/icon_test.go | 24 +++++++++++++++++ internal/experiment/experiment.go | 4 +++ internal/pkg/apps/install.go | 44 ++++++++++++++----------------- 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/internal/api/app.go b/internal/api/app.go index 32bb1df0..a871dcd5 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -59,6 +59,7 @@ type AppsClient interface { GetPresignedS3PostParams(ctx context.Context, token string, appID string) (GenerateS3PresignedPostResult, error) Host() string Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) + IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error) SetHost(host string) UninstallApp(ctx context.Context, token string, appID, teamID string) error diff --git a/internal/api/icon.go b/internal/api/icon.go index efee1f4f..287964fd 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -35,6 +35,8 @@ import ( const ( appIconMethod = "apps.hosted.icon" + // AppIconSetMethod is the API method for setting app icons for non-hosted apps. + AppIconSetMethod = "apps.icon.set" ) // IconResult details to be saved @@ -48,6 +50,16 @@ type iconResponse struct { // Icon updates a Slack App's icon func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { + return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file") +} + +// IconSet sets a Slack App's icon using the apps.icon.set API method. +func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { + return c.uploadIcon(ctx, fs, token, appID, iconFilePath, AppIconSetMethod, "icon") +} + +// uploadIcon uploads an icon to the given API method. +func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath, apiMethod, fileFieldName string) (IconResult, error) { var ( iconBytes []byte err error @@ -81,7 +93,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa var part io.Writer h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", iconStat.Name())) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name())) h.Set("Content-Type", http.DetectContentType(iconBytes)) part, err = writer.CreatePart(h) if err != nil { @@ -101,7 +113,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa writer.Close() var sURL *url.URL - sURL, err = url.Parse(c.host + "/api/" + appIconMethod) + sURL, err = url.Parse(c.host + "/api/" + apiMethod) if err != nil { return IconResult{}, err } diff --git a/internal/api/icon_mock.go b/internal/api/icon_mock.go index f8b0a998..4e792083 100644 --- a/internal/api/icon_mock.go +++ b/internal/api/icon_mock.go @@ -21,6 +21,11 @@ import ( ) func (m *APIMock) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { - args := m.Called(ctx, fs, token, iconFilePath) + args := m.Called(ctx, fs, token, appID, iconFilePath) + return args.Get(0).(IconResult), args.Error(1) +} + +func (m *APIMock) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { + args := m.Called(ctx, fs, token, appID, iconFilePath) return args.Get(0).(IconResult), args.Error(1) } diff --git a/internal/api/icon_test.go b/internal/api/icon_test.go index 1197e67d..78156cc2 100644 --- a/internal/api/icon_test.go +++ b/internal/api/icon_test.go @@ -66,6 +66,30 @@ func TestClient_IconErrorWrongFile(t *testing.T) { require.Contains(t, err.Error(), "unknown format") } +func TestClient_IconSetSuccess(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + fs := afero.NewMemMapFs() + + myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}}) + + for x := range 100 { + for y := range 100 { + c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255} + myimage.Set(x, y, c) + } + } + myfile, _ := fs.Create(imgFile) + err := png.Encode(myfile, myimage) + require.NoError(t, err) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: AppIconSetMethod, + Response: `{"ok":true}`, + }) + defer teardown() + _, err = c.IconSet(ctx, fs, "token", "12345", imgFile) + require.NoError(t, err) +} + func TestClient_IconSuccess(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) fs := afero.NewMemMapFs() diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 375ba188..656eab36 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -39,6 +39,9 @@ const ( // Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes. Sandboxes Experiment = "sandboxes" + // SetIcon experiment enables icon upload for non-hosted apps. + SetIcon Experiment = "set-icon" + // Templates experiment brings more agent templates to the create command. Templates Experiment = "templates" ) @@ -49,6 +52,7 @@ var AllExperiments = []Experiment{ Lipgloss, Placeholder, Sandboxes, + SetIcon, Templates, } diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index 7cf505b9..c530369c 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -24,6 +24,7 @@ import ( "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/api" "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/pkg/manifest" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -521,30 +522,25 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran return app, result, installState, err } - // - // TODO: Currently, cannot update the icon if app is not hosted. - // - // upload icon, default to icon.png - // var iconPath = slackYaml.Icon - // if iconPath == "" { - // if _, err := os.Stat("icon.png"); !os.IsNotExist(err) { - // iconPath = "icon.png" - // } - // } - // if iconPath != "" { - // clients.IO.PrintDebug(ctx, "uploading icon") - // err = updateIcon(ctx, clients, iconPath, env.AppID, token) - // if err != nil { - // clients.IO.PrintError(ctx, "An error occurred updating the Icon", err) - // } - // // Save a md5 hash of the icon in environments.yaml - // var iconHash string - // iconHash, err = getIconHash(iconPath) - // if err != nil { - // return env, api.DeveloperAppInstallResult{}, err - // } - // env.IconHash = iconHash - // } + // upload icon for non-hosted apps (gated behind set-icon experiment) + if clients.Config.WithExperimentOn(experiment.SetIcon) { + var iconPath = slackManifest.Icon + if iconPath == "" { + if _, err := os.Stat("icon.png"); !os.IsNotExist(err) { + iconPath = "icon.png" + } + } + if iconPath != "" { + clients.IO.PrintDebug(ctx, "uploading icon") + _, iconErr := clients.API().IconSet(ctx, clients.Fs, token, app.AppID, iconPath) + if iconErr != nil { + clients.IO.PrintDebug(ctx, "icon error: %s", iconErr) + _, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Error updating app icon: %s", iconErr))) + } else { + _, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Updated app icon: %s", iconPath))) + } + } + } // update config with latest yaml hash // env.Hash = slackYaml.Hash From 2053f5d783103d17163b1ae03d4d12db8409aaaa Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 6 Apr 2026 12:34:00 -0400 Subject: [PATCH 2/6] test: add test coverage --- internal/api/icon_test.go | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/internal/api/icon_test.go b/internal/api/icon_test.go index 78156cc2..a75fe00b 100644 --- a/internal/api/icon_test.go +++ b/internal/api/icon_test.go @@ -66,6 +66,54 @@ func TestClient_IconErrorWrongFile(t *testing.T) { require.Contains(t, err.Error(), "unknown format") } +func TestClient_IconSetErrorIfMissingArgs(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + fs := afero.NewMemMapFs() + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: AppIconSetMethod, + }) + defer teardown() + _, err := c.IconSet(ctx, fs, "token", "", "") + require.Error(t, err) + require.Contains(t, err.Error(), "missing required args") +} + +func TestClient_IconSetErrorNoFile(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + fs := afero.NewMemMapFs() + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: AppIconSetMethod, + }) + defer teardown() + _, err := c.IconSet(ctx, fs, "token", "12345", imgFile) + require.Error(t, err) + require.Contains(t, err.Error(), "file does not exist") +} + +func TestClient_IconSetErrorResponse(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + fs := afero.NewMemMapFs() + + myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}}) + for x := range 100 { + for y := range 100 { + c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255} + myimage.Set(x, y, c) + } + } + myfile, _ := fs.Create(imgFile) + err := png.Encode(myfile, myimage) + require.NoError(t, err) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: AppIconSetMethod, + Response: `{"ok":false,"error":"invalid_app"}`, + }) + defer teardown() + _, err = c.IconSet(ctx, fs, "token", "12345", imgFile) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid_app") +} + func TestClient_IconSetSuccess(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) fs := afero.NewMemMapFs() From 462e6539f359453f43dd3cf614d661bbc3eaa176 Mon Sep 17 00:00:00 2001 From: Ale Mercado <104795114+srtaalej@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:36:47 -0400 Subject: [PATCH 3/6] Apply suggestions from code review Co-authored-by: Eden Zimbelman --- internal/api/icon.go | 5 +++-- internal/pkg/apps/install.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/api/icon.go b/internal/api/icon.go index 287964fd..47ac9347 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -36,7 +36,7 @@ import ( const ( appIconMethod = "apps.hosted.icon" // AppIconSetMethod is the API method for setting app icons for non-hosted apps. - AppIconSetMethod = "apps.icon.set" + appIconSetMethod = "apps.icon.set" ) // IconResult details to be saved @@ -48,7 +48,8 @@ type iconResponse struct { IconResult } -// Icon updates a Slack App's icon +// Icon updates a hosted Slack app icon +// DEPRECATED: Prefer "IconSet" instead func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file") } diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index c530369c..c7fd72a9 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -660,7 +660,7 @@ func updateIcon(ctx context.Context, clients *shared.ClientFactory, iconPath, ap // var iconResp apiclient.IconResult var err error - _, err = clients.API().Icon(ctx, clients.Fs, token, appID, iconPath) + _, err = clients.API().SetIcon(ctx, clients.Fs, token, appID, iconPath) if err != nil { // TODO: separate the icon upload into a different function because if an error is returned // the new app_id might be ignored and next time we'll create another app. From e11c382b3a409dd023a3fd20ec22d32732f9b7dd Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 7 Apr 2026 14:47:25 -0400 Subject: [PATCH 4/6] linter --- internal/api/icon.go | 4 ++-- internal/api/icon_test.go | 8 ++++---- internal/pkg/apps/install.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/icon.go b/internal/api/icon.go index 47ac9347..dd7bf4d5 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -35,7 +35,7 @@ import ( const ( appIconMethod = "apps.hosted.icon" - // AppIconSetMethod is the API method for setting app icons for non-hosted apps. + // appIconSetMethod is the API method for setting app icons for non-hosted apps. appIconSetMethod = "apps.icon.set" ) @@ -56,7 +56,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa // IconSet sets a Slack App's icon using the apps.icon.set API method. func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { - return c.uploadIcon(ctx, fs, token, appID, iconFilePath, AppIconSetMethod, "icon") + return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconSetMethod, "icon") } // uploadIcon uploads an icon to the given API method. diff --git a/internal/api/icon_test.go b/internal/api/icon_test.go index a75fe00b..6fe28e93 100644 --- a/internal/api/icon_test.go +++ b/internal/api/icon_test.go @@ -70,7 +70,7 @@ func TestClient_IconSetErrorIfMissingArgs(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) fs := afero.NewMemMapFs() c, teardown := NewFakeClient(t, FakeClientParams{ - ExpectedMethod: AppIconSetMethod, + ExpectedMethod: appIconSetMethod, }) defer teardown() _, err := c.IconSet(ctx, fs, "token", "", "") @@ -82,7 +82,7 @@ func TestClient_IconSetErrorNoFile(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) fs := afero.NewMemMapFs() c, teardown := NewFakeClient(t, FakeClientParams{ - ExpectedMethod: AppIconSetMethod, + ExpectedMethod: appIconSetMethod, }) defer teardown() _, err := c.IconSet(ctx, fs, "token", "12345", imgFile) @@ -105,7 +105,7 @@ func TestClient_IconSetErrorResponse(t *testing.T) { err := png.Encode(myfile, myimage) require.NoError(t, err) c, teardown := NewFakeClient(t, FakeClientParams{ - ExpectedMethod: AppIconSetMethod, + ExpectedMethod: appIconSetMethod, Response: `{"ok":false,"error":"invalid_app"}`, }) defer teardown() @@ -130,7 +130,7 @@ func TestClient_IconSetSuccess(t *testing.T) { err := png.Encode(myfile, myimage) require.NoError(t, err) c, teardown := NewFakeClient(t, FakeClientParams{ - ExpectedMethod: AppIconSetMethod, + ExpectedMethod: appIconSetMethod, Response: `{"ok":true}`, }) defer teardown() diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index c7fd72a9..7ee1c2f1 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -660,7 +660,7 @@ func updateIcon(ctx context.Context, clients *shared.ClientFactory, iconPath, ap // var iconResp apiclient.IconResult var err error - _, err = clients.API().SetIcon(ctx, clients.Fs, token, appID, iconPath) + _, err = clients.API().IconSet(ctx, clients.Fs, token, appID, iconPath) if err != nil { // TODO: separate the icon upload into a different function because if an error is returned // the new app_id might be ignored and next time we'll create another app. From a126d2af499a90b321242e69cd3a57f1f441ae59 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 7 Apr 2026 14:55:44 -0400 Subject: [PATCH 5/6] set-icon experiment restored --- internal/experiment/experiment.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 7d51b654..80c661f9 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -38,6 +38,9 @@ const ( // Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes. Sandboxes Experiment = "sandboxes" + + // SetIcon experiment enables icon upload for non-hosted apps. + SetIcon Experiment = "set-icon" ) // AllExperiments is a list of all available experiments that can be enabled @@ -46,6 +49,7 @@ var AllExperiments = []Experiment{ Lipgloss, Placeholder, Sandboxes, + SetIcon, } // EnabledExperiments is a list of experiments that are permanently enabled From 54387b67f036b242574fe1b402d47502574e29f5 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Wed, 8 Apr 2026 14:12:08 -0400 Subject: [PATCH 6/6] change api field name --- internal/api/icon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/icon.go b/internal/api/icon.go index dd7bf4d5..5be65442 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -56,7 +56,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa // IconSet sets a Slack App's icon using the apps.icon.set API method. func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) { - return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconSetMethod, "icon") + return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconSetMethod, "file") } // uploadIcon uploads an icon to the given API method.