diff --git a/cmd/blocks/blocks.go b/cmd/blocks/blocks.go new file mode 100644 index 00000000..17414044 --- /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: "Work with Block Kit blocks", + Long: "Work with Block Kit blocks, such as previewing them in the Block Kit Builder.", + Hidden: aiAgentFunc() == nil, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Preview blocks in the 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..48e711c2 --- /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{ + "Work with Block Kit blocks", + "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..4d67d69c --- /dev/null +++ b/cmd/blocks/preview.go @@ -0,0 +1,213 @@ +// 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" + "io" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/goutils" + "github.com/slackapi/slack-cli/internal/prompts" + "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" +) + +// promptTeamSlackAuthFunc is a package variable so it can be stubbed in tests. +var promptTeamSlackAuthFunc = prompts.PromptTeamSlackAuth + +func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { + var blocksFlag string + cmd := &cobra.Command{ + Use: "preview [flags]", + Short: "Preview blocks in the Block Kit Builder", + Long: strings.Join([]string{ + "Open a set of Block Kit blocks in the 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 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 - < 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, fromStdin, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) + if err != nil { + return err + } + + blocksJSON, err := normalizeBlocksPayload(blocksInput) + if err != nil { + return err + } + + auth, err := selectTeamAuth(ctx, clients, fromStdin) + 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: "eyes", + Text: "Block Kit Builder", + Secondary: []string{ + builderURL, + }, + })) + clients.Browser().OpenURL(builderURL) + + clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewSuccess, builderURL) + return nil +} + +func selectTeamAuth(ctx context.Context, clients *shared.ClientFactory, fromStdin bool) (*types.SlackAuth, error) { + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + if len(auths) == 0 { + return nil, slackerror.New(slackerror.ErrCredentialsNotFound) + } + if fromStdin && clients.Config.TeamFlag == "" && len(auths) > 1 { + return nil, slackerror.New(slackerror.ErrMissingFlag). + WithMessage("The team could not be determined"). + WithRemediation("Select a team with the %s flag when reading blocks from standard input", style.Highlight("--team")) + } + return promptTeamSlackAuthFunc(ctx, clients, "Select a team to preview blocks for", nil) +} + +func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, bool, error) { + switch { + case flagChanged && flagValue == "-": + return readStdinBlocks(clients) + case flagChanged: + input := strings.TrimSpace(flagValue) + if input == "" { + return "", false, missingBlocksError() + } + return input, false, nil + default: + return "", false, missingBlocksError() + } +} + +func readStdinBlocks(clients *shared.ClientFactory) (string, bool, error) { + if clients.IO.IsStdinTTY() { + return "", true, slackerror.New(slackerror.ErrMissingInput). + WithMessage("No blocks were provided on standard input"). + WithRemediation("Redirect blocks into the command with %s, e.g. %s", style.Highlight("<"), style.Highlight("slack blocks preview --blocks - < blocks.json")) + } + piped, err := io.ReadAll(clients.IO.ReadIn()) + if err != nil { + return "", true, slackerror.Wrap(err, slackerror.ErrMissingInput) + } + input := strings.TrimSpace(string(piped)) + if input == "" { + return "", true, missingBlocksError() + } + return input, true, 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) { + var parsed any + if err := goutils.JSONUnmarshal([]byte(input), &parsed); err != nil { + return "", err + } + + compacted, err := compactJSON(input) + if err != nil { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + + switch value := parsed.(type) { + case []any: + return fmt.Sprintf(`{"blocks":%s}`, compacted), nil + case map[string]any: + if _, ok := value["blocks"].([]any); !ok { + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } + return compacted, nil + default: + return "", slackerror.New(slackerror.ErrInvalidBlocks) + } +} + +func compactJSON(input string) (string, error) { + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(input)); err != nil { + return "", err + } + return buf.String(), nil +} + +func teamOrEnterpriseID(auth *types.SlackAuth) string { + if auth.IsEnterpriseInstall { + return auth.EnterpriseID + } + return auth.TeamID +} + +func buildBlockKitBuilderURL(apiHost string, id 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 + parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", id) + 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..0853655e --- /dev/null +++ b/cmd/blocks/preview_test.go @@ -0,0 +1,298 @@ +// 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/prompts" + "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 } +} + +// stubTeamAuth stubs the team selection to return the provided auth +func stubTeamAuth(auth *types.SlackAuth) func() { + original := promptTeamSlackAuthFunc + promptTeamSlackAuthFunc = func(ctx context.Context, clients *shared.ClientFactory, promptText string, promptConfig *prompts.PromptTeamSlackAuthConfig) (*types.SlackAuth, error) { + return auth, nil + } + return func() { promptTeamSlackAuthFunc = original } +} + +func Test_Blocks_PreviewCommand(t *testing.T) { + var restore func() + testutil.TableTestCommand(t, testutil.CommandTests{ + "opens the 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") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.BlocksPreviewSuccess, []string{expectedURL}) + }, + Teardown: func() { restore() }, + }, + "opens the 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.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "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") + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{TeamID: "T123"}}, nil) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := `https://app.slack.com/block-kit-builder/T123/builder#%7B%22blocks%22:%5B%7B%22type%22:%22divider%22%7D%5D%7D` + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + }, + Teardown: func() { restore() }, + }, + "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) + }, + }, + "errors 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.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{}, nil) + }, + ExpectedErrorStrings: []string{slackerror.ErrCredentialsNotFound}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "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}, + }, + "errors when reading blocks from stdin 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.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) + }, + ExpectedErrorStrings: []string{slackerror.ErrMissingFlag, "--team"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertNotCalled(t, "OpenURL", mock.Anything) + }, + }, + "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"}}, nil) + cm.IO.Stdin = bytes.NewBufferString(`[{"type":"divider"}]`) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123"}) + }, + 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") + })) + }, + Teardown: func() { restore() }, + }, + "uses the enterprise id for enterprise installs": { + 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{{TeamID: "T123"}}, nil) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: true}) + }, + 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") + })) + }, + Teardown: func() { restore() }, + }, + "uses the team id for org-grid workspace installs": { + 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{{TeamID: "T123"}}, nil) + restore = stubTeamAuth(&types.SlackAuth{TeamID: "T123", EnterpriseID: "E456", IsEnterpriseInstall: false}) + }, + 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") + })) + }, + Teardown: func() { restore() }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewPreviewCommand(cf) + }) +} + +func Test_buildBlockKitBuilderURL(t *testing.T) { + tests := map[string]struct { + apiHost string + id string + blocksJSON string + expected string + expectedErr string + }{ + "production host": { + apiHost: "https://slack.com", + id: "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", + id: "E456", + blocksJSON: `{"blocks":[]}`, + expected: "https://app.dev1234.slack.com/block-kit-builder/E456/builder#%7B%22blocks%22:%5B%5D%7D", + }, + "empty host": { + apiHost: "", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + "scheme-less host": { + apiHost: "app.slack.com", + id: "T123", + blocksJSON: `{"blocks":[]}`, + expectedErr: slackerror.ErrInvalidArguments, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual, err := buildBlockKitBuilderURL(tc.apiHost, tc.id, 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) + }) + } +} 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/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/shared/types/slackdeps.go b/internal/shared/types/slackdeps.go index 858c5201..9f82aae3 100644 --- a/internal/shared/types/slackdeps.go +++ b/internal/shared/types/slackdeps.go @@ -65,4 +65,7 @@ type Os interface { // Stdout returns the file descriptor for stdout Stdout() File + + // Stdin returns the file descriptor for stdin + Stdin() File } diff --git a/internal/slackdeps/os.go b/internal/slackdeps/os.go index 4a353235..5d3edbb4 100644 --- a/internal/slackdeps/os.go +++ b/internal/slackdeps/os.go @@ -98,3 +98,8 @@ func (c *Os) Exit(code int) { func (c *Os) Stdout() types.File { return os.Stdout } + +// Stdin returns the file descriptor for stdin +func (c *Os) Stdin() types.File { + return os.Stdin +} diff --git a/internal/slackdeps/os_mock.go b/internal/slackdeps/os_mock.go index f8f1620f..8bb6065f 100644 --- a/internal/slackdeps/os_mock.go +++ b/internal/slackdeps/os_mock.go @@ -56,6 +56,7 @@ func (m *OsMock) AddDefaultMocks() { m.On("Exit", mock.Anything).Return() m.On("Stat", mock.Anything).Return() m.On("Stdout").Return(os.Stdout) + m.On("Stdin").Return(os.Stdin) } // Getenv mocks returning an environment variable @@ -136,3 +137,9 @@ func (m *OsMock) Stdout() types.File { args := m.Called() return args.Get(0).(types.File) } + +// 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) +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 1959724f..fc82ee6e 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -140,6 +140,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" @@ -937,6 +938,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"