diff --git a/cmd/extension/exec.go b/cmd/extension/exec.go new file mode 100644 index 00000000..dd611901 --- /dev/null +++ b/cmd/extension/exec.go @@ -0,0 +1,109 @@ +// Copyright 2022-2025 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 extension + +import ( + "context" + "strings" + + "github.com/slackapi/slack-cli/internal/cmdutil" + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/hooks" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +func NewExtensionExecCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "exec [flags] [args]", + Hidden: true, + Short: "Run an extension", + Long: strings.Join([]string{ + "Execute the extension with provided arguments", + }, "\n"), + Args: cobra.MinimumNArgs(0), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Run the \"glitch\" extension", + Command: "extension exec glitch", + }, + }), + PreRunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + return preRunExtensionExecCommandFunc(ctx, clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runExtensionExecCommandFunc(clients, cmd, args) + }, + } + + return cmd +} + +// preRunExtensionExecCommandFunc determines if the command is available. +func preRunExtensionExecCommandFunc(ctx context.Context, clients *shared.ClientFactory) error { + if !clients.Config.WithExperimentOn(experiment.Extension) { + return slackerror.New(slackerror.ErrMissingExperiment). + WithRemediation("Run the command again with the \"--experiment extension\" flag.") + } + return nil +} + +// runExtensionExecCommandFunc runs the provided extension. +func runExtensionExecCommandFunc( + clients *shared.ClientFactory, + cmd *cobra.Command, + args []string, +) error { + ctx := cmd.Context() + switch { + case len(args) == 0: + return slackerror.New(slackerror.ErrMissingExtension) + case args[0] == "glitch": + clients.IO.PrintDebug(ctx, ":space_invader:") + clients.IO.PrintInfo(ctx, false, "\x1b[?25l") + return nil + } + opts := hooks.HookExecOpts{ + Hook: hooks.HookScript{ + Name: "extension", + Command: "slack-" + strings.Join(args, " "), + }, + Stdin: clients.IO.ReadIn(), + Stdout: clients.IO.WriteOut(), + Stderr: clients.IO.WriteErr(), + Env: map[string]string{ + "SLACK_CLI_ALIAS": cmdutil.GetProcessName(), + }, + } + // The "default" protocol is used because a certain response is not expected of + // scripts provided to the "extension" hook. + // + // The "message boundaries" protocol appends information to scripts using flags + // which might cause some commands to error. + // + // The hook executor attached to the provided clients might use either protocol + // so we instantiate the default here. + shell := hooks.HookExecutorDefaultProtocol{ + IO: clients.IO, + } + _, err := shell.Execute(ctx, opts) + if err != nil { + return err + } + return nil +} diff --git a/cmd/extension/exec_test.go b/cmd/extension/exec_test.go new file mode 100644 index 00000000..d1b704dd --- /dev/null +++ b/cmd/extension/exec_test.go @@ -0,0 +1,65 @@ +// Copyright 2022-2025 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 extension + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" +) + +func Test_Extension_ExecCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "errors without the experiment flag": { + CmdArgs: []string{"glitch"}, + ExpectedError: slackerror.New(slackerror.ErrMissingExperiment), + }, + "no extension to execute errors": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{"extension"} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedError: slackerror.New(slackerror.ErrMissingExtension), + }, + "the glitch extension exists": { + CmdArgs: []string{"glitch"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{"extension"} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"\x1b[?25l"}, + }, + "attempts to execute a missing extension": { + CmdArgs: []string{"404"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{"extension"} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{ + slackerror.ErrSDKHookInvocationFailed, + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + cmd := NewExtensionExecCommand(cf) + return cmd + }) +} diff --git a/cmd/extension/extension.go b/cmd/extension/extension.go new file mode 100644 index 00000000..9de5efd3 --- /dev/null +++ b/cmd/extension/extension.go @@ -0,0 +1,52 @@ +// Copyright 2022-2025 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 extension + +import ( + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "extension ", + Hidden: true, + Short: "Manage Slack CLI extensions", + Long: strings.Join([]string{ + "Run custom Slack CLI commands as extensions.", + "", + "Commands are installed separate and should be available as \"slack-example\" in", + "the current shell for example.", + "", + "One extension is included for testing purposes.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Run the \"glitch\" extension", + Command: "extension exec glitch", + }, + }), + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewExtensionExecCommand(clients)) + + return cmd +} diff --git a/cmd/extension/extension_test.go b/cmd/extension/extension_test.go new file mode 100644 index 00000000..b6727d4f --- /dev/null +++ b/cmd/extension/extension_test.go @@ -0,0 +1,36 @@ +// Copyright 2022-2025 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 extension + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" +) + +func Test_Extension_Command(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "shows the help page without commands or arguments or flags": { + ExpectedStdoutOutputs: []string{ + "Run custom Slack CLI commands as extensions.", + }, + }, + }, func(clients *shared.ClientFactory) *cobra.Command { + cmd := NewCommand(clients) + return cmd + }) +} diff --git a/cmd/root.go b/cmd/root.go index 0bcf5b3d..26246222 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -29,6 +29,7 @@ import ( "github.com/slackapi/slack-cli/cmd/docgen" "github.com/slackapi/slack-cli/cmd/doctor" "github.com/slackapi/slack-cli/cmd/env" + "github.com/slackapi/slack-cli/cmd/extension" "github.com/slackapi/slack-cli/cmd/externalauth" "github.com/slackapi/slack-cli/cmd/feedback" "github.com/slackapi/slack-cli/cmd/fingerprint" @@ -162,6 +163,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { datastore.NewCommand(clients), docgen.NewCommand(clients), env.NewCommand(clients), + extension.NewCommand(clients), externalauth.NewCommand(clients), fingerprint.NewCommand(clients), function.NewCommand(clients), diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index 9b73a32f..17a3d6b7 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -9,12 +9,14 @@ The following is a list of currently available experiments. We'll remove experim * `bolt-install`: enables creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). * `slack create` and `slack init` now set manifest source to "app settings" (remote) for Bolt JS & Bolt Python projects ([PR#96](https://github.com/slackapi/slack-cli/pull/96)). * `slack run` and `slack install` support creating and installing Bolt Framework apps that have the manifest source set to "app settings (remote)" ([PR#111](https://github.com/slackapi/slack-cli/pull/111), [PR#154](https://github.com/slackapi/slack-cli/pull/154)). +* `extension`: enables running custom programs with the `slack extension` command. * `read-only-collaborators`: enables creating and modifying collaborator permissions via the `slack collaborator` commands. ## Experiments changelog Below is a list of updates related to experiments. +* **September 2025**: Added the experiment `extension`. * **June 2025**: * Updated the `slack run` command to create and install new and existing Bolt framework projects configured with app settings as the source of truth (remote manifest). * Added support for creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). New Bolt projects are now configured to have apps managed by app settings rather than by project. When running a project for local development, the app and bot tokens are automatically set, and no longer require developers to export them as environment variables. Existing Bolt projects will continue to work with a project (local) manifest, and linking an app from app settings will configure the project to be managed by app settings (remote manifest). In an upcoming release, support for installing and deploying apps managed by app settings will be implemented. diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index 65878654..bc101c60 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -38,6 +38,9 @@ const ( // manage their app manifest on app settings (remote manifest). BoltInstall Experiment = "bolt-install" + // Extension experiment unlocks the extension commands. + Extension Experiment = "extension" + // The ReadOnlyAppCollaborators experiment enables creating and modifying collaborator // permissions via the `collaborator` commands. ReadOnlyAppCollaborators Experiment = "read-only-collaborators" @@ -51,6 +54,7 @@ const ( var AllExperiments = []Experiment{ BoltFrameworks, BoltInstall, + Extension, ReadOnlyAppCollaborators, Placeholder, } diff --git a/internal/experiment/experiment_test.go b/internal/experiment/experiment_test.go index b9e94df6..d70731e0 100644 --- a/internal/experiment/experiment_test.go +++ b/internal/experiment/experiment_test.go @@ -26,6 +26,7 @@ func Test_Includes(t *testing.T) { // Test expected experiments require.Equal(t, true, Includes(Experiment("bolt"))) + require.Equal(t, true, Includes(Experiment("extension"))) require.Equal(t, true, Includes(Experiment("read-only-collaborators"))) // Test invalid experiment diff --git a/internal/hooks/hook_executor_default.go b/internal/hooks/hook_executor_default.go index dc259116..968809ef 100644 --- a/internal/hooks/hook_executor_default.go +++ b/internal/hooks/hook_executor_default.go @@ -17,7 +17,9 @@ package hooks import ( "bytes" "context" + "os/exec" "strings" + "syscall" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/slackerror" @@ -72,6 +74,19 @@ func (e *HookExecutorDefaultProtocol) Execute(ctx context.Context, opts HookExec response := strings.TrimSpace(buffout.String()) if err != nil { + // Signal interrupts with an error code when input might be expected + if opts.Stdin != nil { + if ee, ok := err.(*exec.ExitError); ok { + if ws, ok := ee.Sys().(syscall.WaitStatus); ok { + if ws.Signaled() { + switch ws.Signal() { + case syscall.SIGINT, syscall.SIGTERM: + return "", slackerror.New(slackerror.ErrProcessInterrupted) + } + } + } + } + } // Include stderr outputs in error details if these aren't streamed details := slackerror.ErrorDetails{} if opts.Stderr == nil { diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 0b773eb6..61212c21 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -177,6 +177,7 @@ const ( ErrMissingAppTeamID = "missing_app_team_id" ErrMissingChallenge = "missing_challenge" ErrMissingExperiment = "missing_experiment" + ErrMissingExtension = "missing_extension" ErrMissingFunctionIdentifier = "missing_function_identifier" ErrMissingFlag = "missing_flag" ErrMissingInput = "missing_input" @@ -1115,6 +1116,11 @@ Otherwise start your app for local development with: %s`, Message: "The feature is behind an experiment not toggled on", }, + ErrMissingExtension: { + Code: ErrMissingExtension, + Message: "An extension is missing", + }, + ErrMissingFunctionIdentifier: { Code: ErrMissingFunctionIdentifier, Message: "Could not find the given workflow using the specified reference",