diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go new file mode 100644 index 00000000..47d71b44 --- /dev/null +++ b/cmd/blocks/blocks.go @@ -0,0 +1,48 @@ +// 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 blocks + +import ( + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/style" + "github.com/slackapi/slack-cli/internal/useragent" + "github.com/spf13/cobra" +) + +// aiAgentFunc is a package variable so it can be stubbed in tests. +var aiAgentFunc = useragent.GetAIAgent + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "blocks [flags]", + Short: "Build with Block Kit", + Long: "Build layouts using Block Kit and iterate on designs with Block Kit Builder.", + Hidden: aiAgentFunc() == nil, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks in Block Kit Builder", + Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", + }, + }), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewPreviewCommand(clients)) + + return cmd +} diff --git a/cmd/blocks/blocks_test.go b/cmd/blocks/blocks_test.go new file mode 100644 index 00000000..d2980383 --- /dev/null +++ b/cmd/blocks/blocks_test.go @@ -0,0 +1,68 @@ +// 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 blocks + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/useragent" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func Test_Blocks_Command(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "prints help without a subcommand": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + }, + ExpectedOutputs: []string{ + "Build layouts using Block Kit", + "preview", + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCommand(cf) + }) +} + +func Test_Blocks_Command_Hidden(t *testing.T) { + tests := map[string]struct { + aiAgent *useragent.AIAgent + expectedHidden bool + }{ + "hidden when no AI coding tool is detected": { + aiAgent: nil, + expectedHidden: true, + }, + "visible when an AI coding tool is detected": { + aiAgent: &useragent.AIAgent{Name: "claude-code"}, + expectedHidden: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + restore := stubAIAgent(tc.aiAgent) + defer restore() + + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + cmd := NewCommand(clients) + assert.Equal(t, tc.expectedHidden, cmd.Hidden) + }) + } +} diff --git a/cmd/blocks/preview.go b/cmd/blocks/preview.go new file mode 100644 index 00000000..2a1f994e --- /dev/null +++ b/cmd/blocks/preview.go @@ -0,0 +1,216 @@ +// 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 blocks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/goutils" + "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/cobra" +) + +func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { + var blocksFlag string + cmd := &cobra.Command{ + Use: "preview [flags]", + Short: "Preview blocks in Block Kit Builder", + Long: strings.Join([]string{ + "Preview a set of Block Kit blocks with Block Kit Builder in a web browser.", + "", + "Provide blocks with the --blocks flag.", + "The input is a JSON array of blocks or a JSON object with a \"blocks\" array.", + "Pass - to --blocks, or omit all flags, to read from standard input.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks passed as a flag value", + Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", + }, + { + Meaning: "Preview blocks read from a file", + Command: "blocks preview < blocks.json", + }, + { + Meaning: "Preview blocks read from a redirect and scoped to a team", + Command: "blocks preview --team T0123456 --blocks - < blocks.json", + }, + }), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return previewCommandRunE(clients, cmd, blocksFlag, cmd.Flags().Changed("blocks")) + }, + } + cmd.Flags().StringVar(&blocksFlag, "blocks", "", "blocks to preview as a JSON array or object\n (use - to read from standard input)") + return cmd +} + +func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error { + ctx := cmd.Context() + clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) + + blocksInput, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) + if err != nil { + return err + } + + blocksJSON, err := normalizeBlocksPayload(blocksInput) + if err != nil { + return err + } + + auth, err := resolveTeamAuth(ctx, clients) + if err != nil { + return err + } + + builderURL, err := buildBlockKitBuilderURL(clients.API().Host(), teamOrEnterpriseID(auth), blocksJSON) + if err != nil { + return err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "bricks", + Text: "Blocks Preview", + Secondary: []string{ + builderURL, + }, + })) + clients.Browser().OpenURL(builderURL) + + clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewSuccess, builderURL) + return nil +} + +func resolveTeamAuth(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { + if clients.Config.TeamFlag == "" { + return nil, nil + } + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + for i, auth := range auths { + if clients.Config.TeamFlag == auth.TeamID || clients.Config.TeamFlag == auth.TeamDomain { + return &auths[i], nil + } + } + return nil, slackerror.New(slackerror.ErrTeamNotFound) +} + +func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, error) { + switch { + case flagChanged && flagValue == "-": + if clients.IO.IsStdinTTY() { + return "", slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided on standard input"). + WithRemediation("Redirect blocks into the command with \"<\", e.g. %s", style.Commandf("blocks preview < blocks.json", false)) + } + return readStdinBlocks(clients) + case flagChanged: + input := strings.TrimSpace(flagValue) + if input == "" { + return "", missingBlocksError() + } + return input, nil + case !clients.IO.IsStdinTTY(): + return readStdinBlocks(clients) + default: + return "", missingBlocksError() + } +} + +func readStdinBlocks(clients *shared.ClientFactory) (string, error) { + input, err := clients.IO.ReadInAll() + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrMissingInput) + } + if input == "" { + return "", missingBlocksError() + } + return input, nil +} + +func missingBlocksError() error { + return slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided"). + WithRemediation("Provide blocks with the %s flag, or pass %s to read from standard input", style.Highlight("--blocks"), style.Highlight("--blocks -")) +} + +func normalizeBlocksPayload(input string) (string, error) { + compacted, err := goutils.CompactJSON([]byte(input)) + if err != nil { + return "", slackerror.JSONUnmarshalError(err, []byte(input)) + } + trimmed := bytes.TrimSpace(compacted) + if len(trimmed) == 0 { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + + switch trimmed[0] { + case '[': + return fmt.Sprintf(`{"blocks":%s}`, trimmed), nil + case '{': + var object map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &object); err != nil { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + blocks, ok := object["blocks"] + if !ok || len(bytes.TrimSpace(blocks)) == 0 || bytes.TrimSpace(blocks)[0] != '[' { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + return string(trimmed), nil + default: + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } +} + +func teamOrEnterpriseID(auth *types.SlackAuth) string { + if auth == nil { + return "" + } + if auth.IsEnterpriseInstall { + return auth.EnterpriseID + } + return auth.TeamID +} + +func buildBlockKitBuilderURL(apiHost string, teamOrEnterpriseID string, blocksJSON string) (string, error) { + parsed, err := url.Parse(apiHost) + if err != nil { + return "", slackerror.Wrap(err, slackerror.ErrInvalidArguments) + } + if parsed.Host == "" { + return "", slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("The API host %q is not a valid URL", apiHost) + } + parsed.Host = "app." + parsed.Host + if teamOrEnterpriseID != "" { + parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", teamOrEnterpriseID) + } else { + parsed.Path = "/block-kit-builder" + } + parsed.Fragment = blocksJSON + return parsed.String(), nil +} diff --git a/cmd/blocks/preview_test.go b/cmd/blocks/preview_test.go new file mode 100644 index 00000000..ed4bb673 --- /dev/null +++ b/cmd/blocks/preview_test.go @@ -0,0 +1,349 @@ +// 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 blocks + +import ( + "bytes" + "context" + "testing" + + "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/useragent" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// stubAIAgent stubs the detected AI coding tool and returns a function that +// restores the original detection. +func stubAIAgent(agent *useragent.AIAgent) func() { + original := aiAgentFunc + aiAgentFunc = func() *useragent.AIAgent { return agent } + return func() { aiAgentFunc = original } +} + +func Test_Blocks_PreviewCommand(t *testing.T) { + // teamlessURL is the Block Kit Builder URL rendered when no team can be + // resolved. The browser resolves the team from its own session. + const teamlessURL = `https://app.slack.com/block-kit-builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + // teamURL is the Block Kit Builder URL scoped to team T123. + const teamURL = `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + testutil.TableTestCommand(t, testutil.CommandTests{ + "opens the team-less builder with blocks from the --blocks flag": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.BlocksPreviewSuccess, []string{teamlessURL}) + }, + }, + "opens the team-less builder with blocks from stdin via the - sentinel": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "opens the team-less builder with blocks from stdin when the --blocks flag is omitted": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "accepts a blocks object payload": { + CmdArgs: []string{"--blocks", `{"blocks":[{"type":"divider"}]}`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "errors when no blocks are provided": { + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "errors when the --blocks flag is empty": { + CmdArgs: []string{"--blocks", ""}, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "No blocks were provided"}, + }, + "errors when reading from stdin on an interactive terminal": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("IsStdinTTY").Return(true) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingInput, "standard input"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "opens the team-less builder when no teams are logged in": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "opens the team-less builder when the credentials cannot be read": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound)) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "errors when the blocks are not valid json": { + CmdArgs: []string{"--blocks", `not json`}, + ExpectedErrorStrings: []string{slackerror.ErrUnableToParseJSON}, + }, + "errors when the json is not a blocks payload": { + CmdArgs: []string{"--blocks", `{"foo":"bar"}`}, + ExpectedErrorStrings: []string{slackerror.ErrInvalidBlocks}, + }, + "opens the team-less builder with multiple teams and no --team flag": { + CmdArgs: []string{"--blocks", "-"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", TeamDomain: "team-a"}, + {TeamID: "T456", TeamDomain: "team-b"}, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamlessURL) + }, + }, + "opens the builder when reading blocks from stdin with the --team flag set": { + CmdArgs: []string{"--blocks", "-", "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", TeamDomain: "team-a"}, + {TeamID: "T456", TeamDomain: "team-b"}, + }, nil) + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", teamURL) + }, + }, + "errors when the --team flag has no matching auth": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`, "--team", "T999"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) + }, + ExpectedErrorStrings: []string{slackerror.ErrTeamNotFound}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "errors when the --team flag is set but the credentials cannot be read": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`, "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound)) + }, + ExpectedErrorStrings: []string{slackerror.ErrCredentialsNotFound}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "uses the enterprise id for enterprise installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`, "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/E456/builder") + })) + }, + }, + "uses the team id for org-grid workspace installs": { + CmdArgs: []string{"--blocks", `[{"type":"divider"}]`, "--team", "T123"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("Host").Return("https://slack.com") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", mock.MatchedBy(func(url string) bool { + return assert.Contains(t, url, "/block-kit-builder/T123/builder") + })) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewPreviewCommand(cf) + }) +} + +func Test_buildBlockKitBuilderURL(t *testing.T) { + tests := map[string]struct { + apiHost string + teamOrEnterpriseID string + blocksJSON string + expected string + expectedErr string + }{ + "production host": { + apiHost: "https://slack.com", + teamOrEnterpriseID: "T123", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "developer host": { + apiHost: "https://dev1234.slack.com", + teamOrEnterpriseID: "E456", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "team-less builder when the id is empty": { + apiHost: "https://slack.com", + teamOrEnterpriseID: "", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.slack.com/block-kit-builder#%7B%22blocks%22:%5B%5D%7D", + }, + "empty host": { + apiHost: "", + teamOrEnterpriseID: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + "scheme-less host": { + apiHost: "app.slack.com", + teamOrEnterpriseID: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.teamOrEnterpriseID, tc.blocksJSON) + if tc.expectedErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func Test_normalizeBlocksPayload(t *testing.T) { + tests := map[string]struct { + input string + expected string + expectedErr string + }{ + "wraps a bare array": { + input: `[{"type":"divider"}]`, + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "passes through a blocks object": { + input: `{"blocks":[{"type":"divider"}]}`, + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "compacts whitespace": { + input: "[\n {\n \"type\": \"divider\"\n }\n]", + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "preserves key order when wrapping a bare array": { + input: `[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]`, + expected: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + }, + "preserves key order in a blocks object": { + input: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + expected: `{"blocks":[{"type":"section","text":{"type":"mrkdwn","text":"hi"}}]}`, + }, + "rejects invalid json": { + input: `not json`, + expectedErr: slackerror.ErrUnableToParseJSON, + }, + "rejects an object without blocks": { + input: `{"foo":"bar"}`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + "rejects a non array blocks value": { + input: `{"blocks":"nope"}`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + "rejects a scalar value": { + input: `42`, + expectedErr: slackerror.ErrInvalidBlocks, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := normalizeBlocksPayload(tc.input) + if tc.expectedErr != "" { + require.Error(t, err) + assert.Equal(t, tc.expectedErr, slackerror.ToSlackError(err).Code) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func Test_teamOrEnterpriseID(t *testing.T) { + tests := map[string]struct { + auth *types.SlackAuth + expected string + }{ + "returns an empty string when the auth is nil": { + auth: nil, + expected: "", + }, + "returns the team id for workspace installs": { + auth: &types.SlackAuth{TeamID: "T123", EnterpriseID: "E456"}, + expected: "T123", + }, + "returns the enterprise id for enterprise installs": { + auth: &types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}, + expected: "E456", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, teamOrEnterpriseID(tc.auth)) + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index f5d7573f..eeb7eb4f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( apicmd "github.com/slackapi/slack-cli/cmd/api" "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/cmd/auth" + "github.com/slackapi/slack-cli/cmd/blocks" "github.com/slackapi/slack-cli/cmd/collaborators" "github.com/slackapi/slack-cli/cmd/datastore" "github.com/slackapi/slack-cli/cmd/docgen" @@ -167,6 +168,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { apicmd.NewCommand(clients), app.NewCommand(clients), auth.NewCommand(clients), + blocks.NewCommand(clients), collaborators.NewCommand(clients), datastore.NewCommand(clients), docgen.NewCommand(clients), diff --git a/docs/reference/errors.md b/docs/reference/errors.md index a484c3b5..2e51f362 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -820,6 +820,14 @@ Or choose a specific app with `--app ` --- +### invalid_blocks {#invalid_blocks} + +**Message**: The provided blocks are not valid Block Kit blocks + +**Remediation**: Provide a JSON array of blocks or a JSON object with a "blocks" array. Reference: https://docs.slack.dev/reference/block-kit/blocks + +--- + ### invalid_challenge {#invalid_challenge} **Message**: The challenge code is invalid diff --git a/internal/goutils/json.go b/internal/goutils/json.go index 94aeb2af..a19c858a 100644 --- a/internal/goutils/json.go +++ b/internal/goutils/json.go @@ -85,6 +85,16 @@ func JSONUnmarshal(data []byte, v interface{}) error { return nil } +// CompactJSON removes insignificant whitespace from the JSON-encoded data, +// returning the compacted encoding. It is a thin wrapper around json.Compact. +func CompactJSON(data []byte) ([]byte, error) { + var buf bytes.Buffer + if err := json.Compact(&buf, data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + // IsEmptyJSON returns true when the provided bytes are empty or contain only // whitespace. This matches the state left behind when a JSON state file was // truncated but not yet rewritten, e.g. after a process was interrupted between diff --git a/internal/goutils/json_test.go b/internal/goutils/json_test.go index b8f91622..8ac7a03c 100644 --- a/internal/goutils/json_test.go +++ b/internal/goutils/json_test.go @@ -159,6 +159,41 @@ func Test_JSONMarshalUnescapedIndent(t *testing.T) { } } +func Test_CompactJSON(t *testing.T) { + for name, tc := range map[string]struct { + input string + expected string + expectError bool + }{ + "removes insignificant whitespace": { + input: "[\n {\n \"type\": \"divider\"\n }\n]", + expected: `[{"type":"divider"}]`, + }, + "leaves already compact json unchanged": { + input: `{"blocks":[{"type":"divider"}]}`, + expected: `{"blocks":[{"type":"divider"}]}`, + }, + "preserves key order": { + input: `{"type":"section","text":{"type":"mrkdwn","text":"hi"}}`, + expected: `{"type":"section","text":{"type":"mrkdwn","text":"hi"}}`, + }, + "returns an error for invalid json": { + input: `not json`, + expectError: true, + }, + } { + t.Run(name, func(t *testing.T) { + actual, err := CompactJSON([]byte(tc.input)) + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, string(actual)) + }) + } +} + func Test_IsEmptyJSON(t *testing.T) { for name, tc := range map[string]struct { data string diff --git a/internal/iostreams/iostreams.go b/internal/iostreams/iostreams.go index 6dc85ea1..abe4d1ac 100644 --- a/internal/iostreams/iostreams.go +++ b/internal/iostreams/iostreams.go @@ -82,6 +82,10 @@ type IOStreamer interface { // IsTTY returns true if the device is an interactive terminal IsTTY() bool + // IsStdinTTY returns true if stdin is an interactive terminal (not a pipe + // or redirected file) + IsStdinTTY() bool + // GetExitCode returns the most-recently set desired exit code in a thread safe way GetExitCode() ExitCode // SetExitCode sets the desired process exit code in a thread safe way @@ -129,6 +133,18 @@ func (io *IOStreams) IsTTY() bool { } } +// IsStdinTTY returns true if stdin is an interactive terminal +// +// Inspects stdin so that piped or redirected input (e.g. `slack ... < blocks.json`) is detected when +// stdout is still attached to a terminal. +func (io *IOStreams) IsStdinTTY() bool { + if o, err := io.os.Stdin().Stat(); o == nil || err != nil { + return false + } else { + return (o.Mode() & os.ModeCharDevice) == os.ModeCharDevice + } +} + // SetExitCode sets the desired exit code in a thread-safe way func (io *IOStreams) SetExitCode(code ExitCode) { atomic.StoreInt32((*int32)(&io.exitCode), int32(code)) diff --git a/internal/iostreams/iostreams_mock.go b/internal/iostreams/iostreams_mock.go index fcb86ac5..07845f83 100644 --- a/internal/iostreams/iostreams_mock.go +++ b/internal/iostreams/iostreams_mock.go @@ -71,6 +71,7 @@ func NewIOStreamsMock(config *config.Config, fsm *slackdeps.FsMock, osm *slackde // AddDefaultMocks prepares default mock methods to fallback to func (m *IOStreamsMock) AddDefaultMocks() { m.On("IsTTY").Return(false) + m.On("IsStdinTTY").Return(false) m.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) m.On("PrintTrace", mock.Anything, mock.Anything, mock.MatchedBy(func(args []string) bool { return true })) m.On("PrintWarning", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) @@ -97,6 +98,11 @@ func (m *IOStreamsMock) IsTTY() bool { return args.Bool(0) } +func (m *IOStreamsMock) IsStdinTTY() bool { + args := m.Called() + return args.Bool(0) +} + // SetExitCode sets the desired exit code in a thread-safe way func (m *IOStreamsMock) SetExitCode(code ExitCode) { atomic.StoreInt32((*int32)(&m.exitCode), int32(code)) diff --git a/internal/iostreams/iostreams_test.go b/internal/iostreams/iostreams_test.go index 1042a49d..cb8b0790 100644 --- a/internal/iostreams/iostreams_test.go +++ b/internal/iostreams/iostreams_test.go @@ -97,6 +97,38 @@ func Test_IOStreams_IsTTY(t *testing.T) { } } +func Test_IOStreams_IsStdinTTY(t *testing.T) { + tests := map[string]struct { + fileInfo os.FileInfo + expected bool + }{ + "interactive when stdin is a char device": { + fileInfo: &slackdeps.FileInfoCharDevice{}, + expected: true, + }, + "not interactive when stdin is a named pipe": { + fileInfo: &slackdeps.FileInfoNamedPipe{}, + expected: false, + }, + "not interactive when the stat check errors": { + expected: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fsMock := slackdeps.NewFsMock() + osMock := slackdeps.NewOsMock() + config := config.NewConfig(fsMock, osMock) + osMock.On("Stdin").Return(&slackdeps.FileMock{FileInfo: tc.fileInfo}) + io := NewIOStreams(config, fsMock, osMock) + + isStdinTTY := io.IsStdinTTY() + assert.Equal(t, tc.expected, isStdinTTY) + }) + } +} + func Test_IOStreams_SetCmdIO(t *testing.T) { fsMock := slackdeps.NewFsMock() osMock := slackdeps.NewOsMock() diff --git a/internal/iostreams/reader.go b/internal/iostreams/reader.go index 420d3b6e..c35139e7 100644 --- a/internal/iostreams/reader.go +++ b/internal/iostreams/reader.go @@ -15,17 +15,28 @@ package iostreams import ( - "io" + stdio "io" + "strings" ) // Reader contains implementations of a Read methods for various inputs methods // // Only stdin is supported for now type Reader interface { - ReadIn() io.Reader + ReadIn() stdio.Reader + ReadInAll() (string, error) } // ReadIn returns the reader associated with stdin -func (io *IOStreams) ReadIn() io.Reader { +func (io *IOStreams) ReadIn() stdio.Reader { return io.Stdin } + +// ReadInAll reads all of stdin and returns it with surrounding whitespace trimmed +func (io *IOStreams) ReadInAll() (string, error) { + raw, err := stdio.ReadAll(io.Stdin) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/internal/iostreams/reader_mock.go b/internal/iostreams/reader_mock.go index 2bf0459c..04636687 100644 --- a/internal/iostreams/reader_mock.go +++ b/internal/iostreams/reader_mock.go @@ -16,9 +16,19 @@ package iostreams import ( "io" + "strings" ) // ReadIn returns the mocked reader associated with stdin func (m *IOStreamsMock) ReadIn() io.Reader { return m.Stdin } + +// ReadInAll reads all of the mocked stdin and returns it with surrounding whitespace trimmed +func (m *IOStreamsMock) ReadInAll() (string, error) { + raw, err := io.ReadAll(m.Stdin) + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/internal/iostreams/reader_test.go b/internal/iostreams/reader_test.go index 3295f4d0..727fe31f 100644 --- a/internal/iostreams/reader_test.go +++ b/internal/iostreams/reader_test.go @@ -53,3 +53,37 @@ func Test_ReadIn(t *testing.T) { }) } } + +func Test_ReadInAll(t *testing.T) { + tests := map[string]struct { + input string + expected string + }{ + "reads all of stdin": { + input: `[{"type":"divider"}]`, + expected: `[{"type":"divider"}]`, + }, + "trims surrounding whitespace": { + input: "\n hello world \n", + expected: "hello world", + }, + "returns an empty string for empty stdin": { + input: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fsMock := slackdeps.NewFsMock() + osMock := slackdeps.NewOsMock() + cfg := config.NewConfig(fsMock, osMock) + io := NewIOStreams(cfg, fsMock, osMock) + io.Stdin = strings.NewReader(tc.input) + + actual, err := io.ReadInAll() + + assert.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/internal/shared/types/slackdeps.go b/internal/shared/types/slackdeps.go index 858c5201..91739f4d 100644 --- a/internal/shared/types/slackdeps.go +++ b/internal/shared/types/slackdeps.go @@ -63,6 +63,9 @@ type Os interface { // Exit causes the program to exit and return with the status code Exit(code int) + // Stdin returns the file descriptor for stdin + Stdin() File + // Stdout returns the file descriptor for stdout Stdout() File } diff --git a/internal/slackdeps/os.go b/internal/slackdeps/os.go index 4a353235..4c99c778 100644 --- a/internal/slackdeps/os.go +++ b/internal/slackdeps/os.go @@ -94,6 +94,11 @@ func (c *Os) Exit(code int) { os.Exit(code) } +// Stdin returns the file descriptor for stdin +func (c *Os) Stdin() types.File { + return os.Stdin +} + // Stdout returns the file descriptor for stdout func (c *Os) Stdout() types.File { return os.Stdout diff --git a/internal/slackdeps/os_mock.go b/internal/slackdeps/os_mock.go index f8f1620f..562acda7 100644 --- a/internal/slackdeps/os_mock.go +++ b/internal/slackdeps/os_mock.go @@ -55,6 +55,7 @@ func (m *OsMock) AddDefaultMocks() { m.On("IsNotExist", mock.Anything).Return(true) m.On("Exit", mock.Anything).Return() m.On("Stat", mock.Anything).Return() + m.On("Stdin").Return(os.Stdin) m.On("Stdout").Return(os.Stdout) } @@ -131,6 +132,12 @@ func (m *OsMock) Exit(code int) { m.Called(code) } +// Stdin mocks the stdin with a file that can be adjusted +func (m *OsMock) Stdin() types.File { + args := m.Called() + return args.Get(0).(types.File) +} + // Stdout mocks the stdout with a file that can be adjusted func (m *OsMock) Stdout() types.File { args := m.Called() diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 489244d8..4e77700e 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -141,6 +141,7 @@ const ( ErrInvalidArguments = "invalid_arguments" ErrInvalidArgumentsCustomizableInputs = "invalid_arguments_customizable_inputs" ErrInvalidAuth = "invalid_auth" + ErrInvalidBlocks = "invalid_blocks" ErrInvalidChallenge = "invalid_challenge" ErrInvalidChannelID = "invalid_channel_id" ErrInvalidCursor = "invalid_cursor" @@ -943,6 +944,12 @@ Otherwise start your app for local development with: %s`, ), }, + ErrInvalidBlocks: { + Code: ErrInvalidBlocks, + Message: "The provided blocks are not valid Block Kit blocks", + Remediation: "Provide a JSON array of blocks or a JSON object with a \"blocks\" array. Reference: https://docs.slack.dev/reference/block-kit/blocks", + }, + ErrInvalidChallenge: { Code: ErrInvalidChallenge, Message: "The challenge code is invalid", diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index 9ebc08ec..727b0690 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -57,6 +57,8 @@ const ( AuthLogoutSuccess = "SLACK_TRACE_AUTH_LOGOUT_SUCCESS" AuthRevokeStart = "SLACK_TRACE_AUTH_REVOKE_START" AuthRevokeSuccess = "SLACK_TRACE_AUTH_REVOKE_SUCCESS" + BlocksPreviewStart = "SLACK_TRACE_BLOCKS_PREVIEW_START" + BlocksPreviewSuccess = "SLACK_TRACE_BLOCKS_PREVIEW_SUCCESS" CollaboratorAddCollaborator = "SLACK_TRACE_COLLABORATOR_ADD_COLLABORATOR" CollaboratorAddSuccess = "SLACK_TRACE_COLLABORATOR_ADD_SUCCESS" CollaboratorListCollaborator = "SLACK_TRACE_COLLABORATOR_LIST_COLLABORATOR"