From 5bd98a810b577ddba728e836db97254cff3a504d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 17 Mar 2026 22:47:10 -0700 Subject: [PATCH 1/4] fix: unquote environment variable passed to hook commands --- internal/hooks/hook_executor_default_test.go | 8 ++++---- internal/hooks/hook_executor_v2_test.go | 12 ++++++------ internal/hooks/hooks.go | 4 +++- internal/pkg/platform/localserver.go | 5 +++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/internal/hooks/hook_executor_default_test.go b/internal/hooks/hook_executor_default_test.go index dba9cf9a..6c343a70 100644 --- a/internal/hooks/hook_executor_default_test.go +++ b/internal/hooks/hook_executor_default_test.go @@ -60,8 +60,8 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { opts: HookExecOpts{ Hook: HookScript{Name: "happypath", Command: "echo {}"}, Env: map[string]string{ - "batman": "robin", - "yin": "yang", + "BATMAN": "robin hood", + "YIN": "yang", }, Exec: &MockExec{ mockCommand: &MockCommand{ @@ -74,8 +74,8 @@ func Test_Hook_Execute_Default_Protocol(t *testing.T) { response, err := executor.Execute(ctx, opts) require.Equal(t, "test output", response) require.Equal(t, nil, err) - require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `batman="robin"`) - require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `yin="yang"`) + require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `BATMAN=robin hood`) + require.Contains(t, opts.Exec.(*MockExec).mockCommand.Env, `YIN=yang`) }, }, "failed execution": { diff --git a/internal/hooks/hook_executor_v2_test.go b/internal/hooks/hook_executor_v2_test.go index ee44088a..bd1c21a7 100644 --- a/internal/hooks/hook_executor_v2_test.go +++ b/internal/hooks/hook_executor_v2_test.go @@ -55,8 +55,8 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { opts: HookExecOpts{ Hook: HookScript{Name: "happypath", Command: "echo {}"}, Env: map[string]string{ - "batman": "robin", - "yin": "yang", + "BATMAN": "robin hood", + "YIN": "yang", }, Exec: &MockExec{ mockCommand: &MockCommand{ @@ -68,8 +68,8 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { check: func(t *testing.T, response string, err error, mockExec ExecInterface) { require.Equal(t, `{"message": "hello world"}`, response) require.Equal(t, nil, err) - require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `batman="robin"`) - require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `yin="yang"`) + require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `BATMAN=robin hood`) + require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `YIN=yang`) }, }, "successful execution with payload > 64kb": { @@ -89,8 +89,8 @@ func Test_Hook_Execute_V2_Protocol(t *testing.T) { check: func(t *testing.T, response string, err error, mockExec ExecInterface) { require.Equal(t, sixtyFourKBString, response) require.Equal(t, nil, err) - require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `batman="robin"`) - require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `yin="yang"`) + require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `batman=robin`) + require.Contains(t, mockExec.(*MockExec).mockCommand.Env, `yin=yang`) }, }, "successful execution with payload > 512kb": { diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index dcf3a33a..49ebf8a6 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -57,7 +57,9 @@ func processExecOpts(opts HookExecOpts) ([]string, []string, []string, error) { // To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment. // before adding any new environment variables. var cmdEnvVars = os.Environ() - cmdEnvVars = append(cmdEnvVars, goutils.MapToStringSlice(opts.Env, "")...) + for name, value := range opts.Env { + cmdEnvVars = append(cmdEnvVars, name+"="+value) + } return cmdArgs, cmdArgVars, cmdEnvVars, nil } diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index ed1735bc..28b87041 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -30,7 +30,6 @@ import ( "github.com/gorilla/websocket" "github.com/radovskyb/watcher" "github.com/slackapi/slack-cli/internal/config" - "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/hooks" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/pkg/apps" @@ -307,7 +306,9 @@ func (r *LocalServer) StartDelegate(ctx context.Context) error { // To avoid removing any environment variables that are set in the current environment, we first set the cmd.Env to the current environment. // before adding any new environment variables. var cmdEnvVars = os.Environ() - cmdEnvVars = append(cmdEnvVars, goutils.MapToStringSlice(sdkManagedConnectionStartHookOpts.Env, "")...) + for name, value := range sdkManagedConnectionStartHookOpts.Env { + cmdEnvVars = append(cmdEnvVars, name+"="+value) + } cmd := sdkManagedConnectionStartHookOpts.Exec.Command(cmdEnvVars, os.Stdout, os.Stderr, nil, cmdArgs[0], cmdArgVars...) // Store command reference for lifecycle management From bc648655e6bc25a65d18c00bc0b5848e33f4893f Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 18 Mar 2026 23:19:36 -0700 Subject: [PATCH 2/4] feat: add optional filepath argument to run command --- cmd/platform/run.go | 16 +++++++++-- cmd/platform/run_test.go | 34 +++++++++++++++++++++++ docs/reference/hooks/index.md | 2 +- internal/pkg/platform/localserver.go | 14 +++++++--- internal/pkg/platform/localserver_test.go | 30 ++++++++++---------- internal/pkg/platform/run.go | 2 ++ 6 files changed, 77 insertions(+), 21 deletions(-) diff --git a/cmd/platform/run.go b/cmd/platform/run.go index 5e606ad2..2ff17862 100644 --- a/cmd/platform/run.go +++ b/cmd/platform/run.go @@ -47,13 +47,14 @@ var runAppSelectPromptFunc = prompts.AppSelectPrompt func NewRunCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ - Use: "run", + Use: "run [app-path]", Aliases: []string{"dev", "start-dev"}, // Aliases a few proposed alternative names Short: "Start a local server to develop and run the app locally", Long: `Start a local server to develop and run the app locally while watching for file changes`, + Args: cobra.MaximumNArgs(1), Example: style.ExampleCommandsf([]style.ExampleCommand{ {Command: "platform run", Meaning: "Start a local development server"}, - {Command: "platform run --activity-level debug", Meaning: "Run a local development server with debug activity"}, + {Command: "platform run ./src/app.py", Meaning: "Run a local development server with a custom app entry point"}, {Command: "platform run --cleanup", Meaning: "Run a local development server with cleanup"}, }), PreRunE: func(cmd *cobra.Command, args []string) error { @@ -96,6 +97,16 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str } ctx := cmd.Context() + var appPath string + if len(args) > 0 { + appPath = args[0] + if _, err := clients.Fs.Stat(appPath); err != nil { + return slackerror.New(slackerror.ErrNotFound). + WithMessage("The app path %q could not be found", appPath). + WithRemediation("Check that the file exists and the path is correct") + } + } + // Get the workspace from the flag or prompt selection, err := runAppSelectPromptFunc(ctx, clients, prompts.ShowLocalOnly, prompts.ShowAllApps) if err != nil { @@ -136,6 +147,7 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str Activity: !runFlags.noActivity, ActivityLevel: runFlags.activityLevel, App: selection.App, + AppPath: appPath, Auth: selection.Auth, Cleanup: runFlags.cleanup, ShowTriggers: triggers.ShowTriggers(clients, runFlags.hideTriggers), diff --git a/cmd/platform/run_test.go b/cmd/platform/run_test.go index ef656c67..1173cc7e 100644 --- a/cmd/platform/run_test.go +++ b/cmd/platform/run_test.go @@ -28,6 +28,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/style" "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -54,6 +55,7 @@ func (m *RunPkgMock) Run(ctx context.Context, clients *shared.ClientFactory, run func TestRunCommand_Flags(t *testing.T) { tests := map[string]struct { + setup func(cm *shared.ClientsMock) cmdArgs []string appFlag string tokenFlag string @@ -186,6 +188,35 @@ func TestRunCommand_Flags(t *testing.T) { }, expectedErr: slackerror.New(slackerror.ErrProcessInterrupted), }, + "Positional arg sets AppPath": { + setup: func(cm *shared.ClientsMock) { + _ = afero.WriteFile(cm.Fs, "./src/app.py", []byte(""), 0644) + }, + cmdArgs: []string{"./src/app.py"}, + selectedAppAuth: prompts.SelectedApp{ + App: types.NewApp(), + Auth: types.SlackAuth{}, + }, + expectedRunArgs: platform.RunArgs{ + Activity: true, + ActivityLevel: "info", + App: types.NewApp(), + AppPath: "./src/app.py", + Auth: types.SlackAuth{}, + Cleanup: false, + ShowTriggers: true, + }, + }, + "Error if app path does not exist": { + cmdArgs: []string{"./nonexistent/app.py"}, + selectedAppAuth: prompts.SelectedApp{ + App: types.NewApp(), + Auth: types.SlackAuth{}, + }, + expectedErr: slackerror.New(slackerror.ErrNotFound). + WithMessage("The app path %q could not be found", "./nonexistent/app.py"). + WithRemediation("Check that the file exists and the path is correct"), + }, "Error if no apps are available when using a remote manifest source": { selectedAppErr: slackerror.New(slackerror.ErrMissingOptions), expectedErr: slackerror.New(slackerror.ErrAppNotFound). @@ -214,6 +245,9 @@ func TestRunCommand_Flags(t *testing.T) { clients.Config.AppFlag = tc.appFlag clients.Config.TokenFlag = tc.tokenFlag }) + if tc.setup != nil { + tc.setup(clientsMock) + } appSelectMock := prompts.NewAppSelectMock() appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowLocalOnly, prompts.ShowAllApps).Return(tc.selectedAppAuth, tc.selectedAppErr) diff --git a/docs/reference/hooks/index.md b/docs/reference/hooks/index.md index b41343a7..4d9d2d5d 100644 --- a/docs/reference/hooks/index.md +++ b/docs/reference/hooks/index.md @@ -216,7 +216,7 @@ The application's app-level token and bot access token will be provided as envir All Bolt SDKs leverage this `start` hook operating mode. -A custom start path can be set with the `SLACK_CLI_CUSTOM_FILE_PATH` variable. +A custom start path can be provided as a positional argument to the `run` command (e.g., `slack run ./src/app.py`), which sets both the `SLACK_APP_PATH` and `SLACK_CLI_CUSTOM_FILE_PATH` environment variables for the hook process. ##### Output diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index 28b87041..320f3154 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -81,6 +81,7 @@ type LocalServer struct { token string localHostedContext LocalHostedContext cliConfig hooks.SDKCLIConfig + appPath string Connection WebSocketConnection delegateCmd hooks.ShellCommand // track running delegated process delegateCmdMutex sync.Mutex // protect concurrent access @@ -279,11 +280,16 @@ func (r *LocalServer) stopDelegateProcess(ctx context.Context) { // connection for running app locally to script hook start func (r *LocalServer) StartDelegate(ctx context.Context) error { // Set up hook execution options + env := map[string]string{ + "SLACK_CLI_XAPP": r.token, + "SLACK_CLI_XOXB": r.localHostedContext.BotAccessToken, + } + if r.appPath != "" { + env["SLACK_APP_PATH"] = r.appPath + env["SLACK_CLI_CUSTOM_FILE_PATH"] = r.appPath + } var sdkManagedConnectionStartHookOpts = hooks.HookExecOpts{ - Env: map[string]string{ - "SLACK_CLI_XAPP": r.token, - "SLACK_CLI_XOXB": r.localHostedContext.BotAccessToken, - }, + Env: env, Exec: hooks.ShellExec{}, Hook: r.clients.SDKConfig.Hooks.Start, } diff --git a/internal/pkg/platform/localserver_test.go b/internal/pkg/platform/localserver_test.go index e0b061b3..15378a52 100644 --- a/internal/pkg/platform/localserver_test.go +++ b/internal/pkg/platform/localserver_test.go @@ -140,13 +140,14 @@ func Test_LocalServer_Start(t *testing.T) { TeamID: "justiceleague", } server := LocalServer{ - clients, - "ABC123", - localContext, - clients.SDKConfig, - conn, - nil, - sync.Mutex{}, + clients: clients, + token: "ABC123", + localHostedContext: localContext, + cliConfig: clients.SDKConfig, + appPath: "", + Connection: conn, + delegateCmd: nil, + delegateCmdMutex: sync.Mutex{}, } if tc.fakeDialer != nil { orig := *WebsocketDialerDial @@ -349,13 +350,14 @@ func Test_LocalServer_Listen(t *testing.T) { TeamID: "justiceleague", } server := LocalServer{ - clients, - "ABC123", - localContext, - clients.SDKConfig, - conn, - nil, - sync.Mutex{}, + clients: clients, + token: "ABC123", + localHostedContext: localContext, + cliConfig: clients.SDKConfig, + appPath: "", + Connection: conn, + delegateCmd: nil, + delegateCmdMutex: sync.Mutex{}, } tc.Test(t, ctx, clientsMock, &server, conn) }) diff --git a/internal/pkg/platform/run.go b/internal/pkg/platform/run.go index d78a7d74..f2bf3967 100644 --- a/internal/pkg/platform/run.go +++ b/internal/pkg/platform/run.go @@ -36,6 +36,7 @@ type RunArgs struct { Activity bool ActivityLevel string App types.App + AppPath string Auth types.SlackAuth Cleanup bool ShowTriggers bool @@ -126,6 +127,7 @@ func Run(ctx context.Context, clients *shared.ClientFactory, runArgs RunArgs) (t token: localInstallResult.APIAccessTokens.AppLevel, localHostedContext: localHostedContext, cliConfig: cliConfig, + appPath: runArgs.AppPath, Connection: nil, } From 4416d882d9529aab071c89c1bd690d8011fe27bb Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 23 Mar 2026 10:13:56 -0700 Subject: [PATCH 3/4] style: make more clear the app file path argument Co-authored-by: Michael Brooks --- cmd/platform/run.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/platform/run.go b/cmd/platform/run.go index 2ff17862..8610370f 100644 --- a/cmd/platform/run.go +++ b/cmd/platform/run.go @@ -47,7 +47,7 @@ var runAppSelectPromptFunc = prompts.AppSelectPrompt func NewRunCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ - Use: "run [app-path]", + Use: "run [app-file-path]", Aliases: []string{"dev", "start-dev"}, // Aliases a few proposed alternative names Short: "Start a local server to develop and run the app locally", Long: `Start a local server to develop and run the app locally while watching for file changes`, @@ -102,7 +102,7 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str appPath = args[0] if _, err := clients.Fs.Stat(appPath); err != nil { return slackerror.New(slackerror.ErrNotFound). - WithMessage("The app path %q could not be found", appPath). + WithMessage("The app file path %q could not be found", appPath). WithRemediation("Check that the file exists and the path is correct") } } @@ -147,7 +147,7 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str Activity: !runFlags.noActivity, ActivityLevel: runFlags.activityLevel, App: selection.App, - AppPath: appPath, + AppFilePath: appFilePath, Auth: selection.Auth, Cleanup: runFlags.cleanup, ShowTriggers: triggers.ShowTriggers(clients, runFlags.hideTriggers), From 2428bd86232db5155716f4f9ce76abc08494ede6 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 23 Mar 2026 10:19:13 -0700 Subject: [PATCH 4/4] refactor: continue preference for app file path Co-authored-by: Michael Brooks --- cmd/platform/run.go | 10 +++++----- cmd/platform/run_test.go | 8 ++++---- internal/pkg/platform/localserver.go | 8 ++++---- internal/pkg/platform/localserver_test.go | 4 ++-- internal/pkg/platform/run.go | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/cmd/platform/run.go b/cmd/platform/run.go index 8610370f..14cdea8b 100644 --- a/cmd/platform/run.go +++ b/cmd/platform/run.go @@ -97,12 +97,12 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str } ctx := cmd.Context() - var appPath string + var appFilePath string if len(args) > 0 { - appPath = args[0] - if _, err := clients.Fs.Stat(appPath); err != nil { + appFilePath = args[0] + if _, err := clients.Fs.Stat(appFilePath); err != nil { return slackerror.New(slackerror.ErrNotFound). - WithMessage("The app file path %q could not be found", appPath). + WithMessage("The app file path %q could not be found", appFilePath). WithRemediation("Check that the file exists and the path is correct") } } @@ -147,7 +147,7 @@ func RunRunCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []str Activity: !runFlags.noActivity, ActivityLevel: runFlags.activityLevel, App: selection.App, - AppFilePath: appFilePath, + AppFilePath: appFilePath, Auth: selection.Auth, Cleanup: runFlags.cleanup, ShowTriggers: triggers.ShowTriggers(clients, runFlags.hideTriggers), diff --git a/cmd/platform/run_test.go b/cmd/platform/run_test.go index 1173cc7e..517b5c92 100644 --- a/cmd/platform/run_test.go +++ b/cmd/platform/run_test.go @@ -188,7 +188,7 @@ func TestRunCommand_Flags(t *testing.T) { }, expectedErr: slackerror.New(slackerror.ErrProcessInterrupted), }, - "Positional arg sets AppPath": { + "Positional arg sets AppFilePath": { setup: func(cm *shared.ClientsMock) { _ = afero.WriteFile(cm.Fs, "./src/app.py", []byte(""), 0644) }, @@ -201,20 +201,20 @@ func TestRunCommand_Flags(t *testing.T) { Activity: true, ActivityLevel: "info", App: types.NewApp(), - AppPath: "./src/app.py", + AppFilePath: "./src/app.py", Auth: types.SlackAuth{}, Cleanup: false, ShowTriggers: true, }, }, - "Error if app path does not exist": { + "Error if app file path does not exist": { cmdArgs: []string{"./nonexistent/app.py"}, selectedAppAuth: prompts.SelectedApp{ App: types.NewApp(), Auth: types.SlackAuth{}, }, expectedErr: slackerror.New(slackerror.ErrNotFound). - WithMessage("The app path %q could not be found", "./nonexistent/app.py"). + WithMessage("The app file path %q could not be found", "./nonexistent/app.py"). WithRemediation("Check that the file exists and the path is correct"), }, "Error if no apps are available when using a remote manifest source": { diff --git a/internal/pkg/platform/localserver.go b/internal/pkg/platform/localserver.go index 320f3154..ca038042 100644 --- a/internal/pkg/platform/localserver.go +++ b/internal/pkg/platform/localserver.go @@ -81,7 +81,7 @@ type LocalServer struct { token string localHostedContext LocalHostedContext cliConfig hooks.SDKCLIConfig - appPath string + appFilePath string Connection WebSocketConnection delegateCmd hooks.ShellCommand // track running delegated process delegateCmdMutex sync.Mutex // protect concurrent access @@ -284,9 +284,9 @@ func (r *LocalServer) StartDelegate(ctx context.Context) error { "SLACK_CLI_XAPP": r.token, "SLACK_CLI_XOXB": r.localHostedContext.BotAccessToken, } - if r.appPath != "" { - env["SLACK_APP_PATH"] = r.appPath - env["SLACK_CLI_CUSTOM_FILE_PATH"] = r.appPath + if r.appFilePath != "" { + env["SLACK_APP_PATH"] = r.appFilePath + env["SLACK_CLI_CUSTOM_FILE_PATH"] = r.appFilePath } var sdkManagedConnectionStartHookOpts = hooks.HookExecOpts{ Env: env, diff --git a/internal/pkg/platform/localserver_test.go b/internal/pkg/platform/localserver_test.go index 15378a52..a3e530dc 100644 --- a/internal/pkg/platform/localserver_test.go +++ b/internal/pkg/platform/localserver_test.go @@ -144,7 +144,7 @@ func Test_LocalServer_Start(t *testing.T) { token: "ABC123", localHostedContext: localContext, cliConfig: clients.SDKConfig, - appPath: "", + appFilePath: "", Connection: conn, delegateCmd: nil, delegateCmdMutex: sync.Mutex{}, @@ -354,7 +354,7 @@ func Test_LocalServer_Listen(t *testing.T) { token: "ABC123", localHostedContext: localContext, cliConfig: clients.SDKConfig, - appPath: "", + appFilePath: "", Connection: conn, delegateCmd: nil, delegateCmdMutex: sync.Mutex{}, diff --git a/internal/pkg/platform/run.go b/internal/pkg/platform/run.go index f2bf3967..78380642 100644 --- a/internal/pkg/platform/run.go +++ b/internal/pkg/platform/run.go @@ -36,7 +36,7 @@ type RunArgs struct { Activity bool ActivityLevel string App types.App - AppPath string + AppFilePath string Auth types.SlackAuth Cleanup bool ShowTriggers bool @@ -127,7 +127,7 @@ func Run(ctx context.Context, clients *shared.ClientFactory, runArgs RunArgs) (t token: localInstallResult.APIAccessTokens.AppLevel, localHostedContext: localHostedContext, cliConfig: cliConfig, - appPath: runArgs.AppPath, + appFilePath: runArgs.AppFilePath, Connection: nil, }