From 4d6a19efc30f53560c767af70a4db1f703c814f4 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Fri, 3 Apr 2026 14:41:21 -0400 Subject: [PATCH] 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