Skip to content
11 changes: 7 additions & 4 deletions cmd/platform/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,14 @@ func newRunLogger(clients *shared.ClientFactory, cmd *cobra.Command) *logger.Log
case "on_cloud_run_watch_error":
message := event.DataToString("cloud_run_watch_error")
clients.IO.PrintError(ctx, "Error: %s", message)
case "on_cloud_run_watch_file_change":
path := event.DataToString("cloud_run_watch_file_change")
cmd.Println(style.Secondary(fmt.Sprintf("File change detected: %s, reinstalling app...", path)))
case "on_cloud_run_watch_file_change_reinstalled":
case "on_cloud_run_watch_manifest_change":
path := event.DataToString("cloud_run_watch_manifest_change")
cmd.Println(style.Secondary(fmt.Sprintf("Manifest change detected: %s, reinstalling app...", path)))
case "on_cloud_run_watch_manifest_change_reinstalled":
cmd.Println(style.Secondary("App successfully reinstalled"))
case "on_cloud_run_watch_app_change":
path := event.DataToString("cloud_run_watch_app_change")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise ⭐

cmd.Println(style.Secondary(fmt.Sprintf("App change detected: %s, restarting server...", path)))
case "on_cleanup_app_install_done":
cmd.Println(style.Secondary(fmt.Sprintf(
`Cleaned up local app install for "%s".`,
Expand Down
56 changes: 52 additions & 4 deletions docs/reference/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,17 @@ The format for the JSON representing the CLI-SDK interface is as follows:
},
"config": {
"protocol-version": ["message-boundaries"],
"sdk-managed-connection-enabled": false
"sdk-managed-connection-enabled": false,
"watch": {
"manifest": {
"paths": ["manifest.json"]
},
"app": {
"paths": ["app.js", "listeners/"],
"filter-regex": "\\.(ts|js)$"
}
},
"trigger-paths": ["triggers/"]
}
}
```
Expand All @@ -457,7 +467,7 @@ The format for the JSON representing the CLI-SDK interface is as follows:
| hooks | Object whose keys must match the hook names outlined in the above [Hooks Specification](#specification). Arguments can be provided within this string by separating them with spaces. | Required |
| config | Object of key-value settings. | Optional |
| config.protocol-version | Array of strings representing the named CLI-SDK protocols supported by the SDK, in descending order of support, as in the first element in the array defines the preferred protocol for use by the SDK, the second element defines the next-preferred protocol, and so on. The only supported named protocol currently is `message-boundaries`. The CLI will use the v1 protocol if this field is not provided. | Optional |
| config.watch | Object with configuration settings for file-watching. | Optional |
| config.watch | Object with configuration settings for file-watching during `slack run`. Supports updating the `manifest` on change and reloading the `app` server. Read [Watch configurations](#watch-configurations) for details. | Optional |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Out-of-scope of this PR, but I feel we should start to seriously consider a config.json version field. It would allow the CLI to know whether it supports the version of the config file and help developers upgrade when using an older CLI with a newer project.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwbrooks Great callout! I agree so much. We've been careful to make backward compatible changes but adding that field soon would give me more confidence for whatever might happen later.

| config.sdk-managed-connection-enabled | Boolean specifying whether the WebSocket connection between the CLI and Slack should be managed by the CLI or by the SDK during `slack run` executions. If `true`, the SDK will manage this connection. If `false` or not provided, the CLI will manage this connection. | Optional |
| config.trigger-paths | Array of strings that are paths to files of trigger definitions. | Optional |

Expand All @@ -466,6 +476,37 @@ This format must be adhered to, in order of preference, either:
1. As the response to `get-hooks`, or
2. Comprising the contents of the `hooks.json` file

### Watch configurations {#watch-configurations}

The `config.watch` setting looks for file changes during local development with the `slack run` command. The CLI supports separate file watchers for **manifest** changes and changes to **application code** as options for reinstalling the app or reloading the server.

```json
{
"config": {
"watch": {
"manifest": {
"paths": ["manifest.json"]
},
"app": {
"paths": ["app.js", "listeners/"],
"filter-regex": "\\.(ts|js)$"
}
}
}
}
```

| Field | Description | Required |
| --------------------------- | ---------------------------------------------------------------------------------- | -------- |
| watch.manifest | Object configuring the manifest watcher for reinstalling the app. | Optional |
| watch.manifest.paths | Array of file paths or directories to watch for manifest changes. | Required |
| watch.manifest.filter-regex | Regex pattern to filter which files trigger manifest reinstall (e.g., `\\.json$`). | Optional |
| watch.app | Object configuring the app watcher for restarting the app server. | Optional |
| watch.app.paths | Array of file paths or directories to watch for app/code changes. | Required |
| watch.app.filter-regex | Regex pattern to filter which files trigger server reload (e.g., `\\.(ts\|js)$`). | Optional |

**Note:** For backward compatibility, top-level `paths` and `filter-regex` fields are treated as manifest watching configuration only. No server reloading will occur with the legacy structure.

## Hook resolution {#hook-resolution}

The CLI will employ the following algorithm in order to resolve the command to be executed for a particular hook:
Expand Down Expand Up @@ -516,8 +557,13 @@ The CLI will employ the following algorithm in order to resolve the command to b
},
"config": {
"watch": {
"filter-regex": "^manifest\\.(ts|js|json)$",
"paths": ["."]
"manifest": {
"paths": ["manifest.json"]
},
"app": {
"paths": ["app.js", "listeners/"],
"filter-regex": "\\.(ts|js)$"
}
},
"sdk-managed-connection-enabled": "true"
}
Expand All @@ -543,6 +589,8 @@ The CLI will employ the following algorithm in order to resolve the command to b
}
```

**Note:** The legacy format (top-level `paths` and `filter-regex`) is treated as manifest watching only. No server reloading will occur with this configuration.

## Terms {#terms}

### Types of developers
Expand Down
67 changes: 67 additions & 0 deletions internal/hooks/sdk_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package hooks

import (
"fmt"
"strings"

"github.com/slackapi/slack-cli/internal/slackerror"
Expand Down Expand Up @@ -67,7 +68,22 @@ func (pv ProtocolVersions) Preferred() Protocol {
return HookProtocolDefault
}

// WatchOpts contains details of file watcher configurations
type WatchOpts struct {
Manifest *ManifestWatchOpts `json:"manifest,omitempty"`
App *AppWatchOpts `json:"app,omitempty"`
FilterRegex string `json:"filter-regex,omitempty"` // Legacy for manifest watching
Paths []string `json:"paths,omitempty"` // Legacy for manifest watching
}

// ManifestWatchOpts configures watching for manifest changes for reinstall
type ManifestWatchOpts struct {
FilterRegex string `json:"filter-regex,omitempty"`
Paths []string `json:"paths,omitempty"`
}

// AppWatchOpts configures watching for app/code changes for server restart
type AppWatchOpts struct {
FilterRegex string `json:"filter-regex,omitempty"`
Paths []string `json:"paths,omitempty"`
}
Expand All @@ -76,3 +92,54 @@ type WatchOpts struct {
func (w *WatchOpts) IsAvailable() bool {
return w != nil
}

// GetManifestWatchConfig returns manifest watch config
func (w *WatchOpts) GetManifestWatchConfig() (paths []string, filterRegex string, enabled bool) {
Comment on lines +96 to +97
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Since GetManifestWatchConfig and GetAppWatchConfig access a w *WatchOpts pointer, we may want to check that w != nil otherwise the CLI will panic when accessing a nil pointer.

Non-blocker for this PR because we have a lot of unchecked pointers like this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwbrooks What a nice catch! Let's be diligent toward this in changes more 🤖

I added 5c176af to cover this case in hopes that errors are caught in code and expected!

if w == nil {
return nil, "", false
}
if w.Manifest != nil {
return w.Manifest.Paths, w.Manifest.FilterRegex, len(w.Manifest.Paths) > 0
}
// Backward compatibility: top-level paths/filter-regex for manifest watching
return w.Paths, w.FilterRegex, len(w.Paths) > 0
}

// GetAppWatchConfig returns app watch config
func (w *WatchOpts) GetAppWatchConfig() (paths []string, filterRegex string, enabled bool) {
if w == nil {
return nil, "", false
}
if w.App != nil {
return w.App.Paths, w.App.FilterRegex, len(w.App.Paths) > 0
}
return nil, "", false
}

// String formats WatchOpts for debug outputs
func (w WatchOpts) String() string {
var parts []string
if w.Manifest != nil {
parts = append(parts, fmt.Sprintf("Manifest:%s", w.Manifest.String()))
} else if len(w.Paths) > 0 || w.FilterRegex != "" {
parts = append(parts, fmt.Sprintf("Paths:%v", w.Paths))
parts = append(parts, fmt.Sprintf("FilterRegex:%s", w.FilterRegex))
}
if w.App != nil {
parts = append(parts, fmt.Sprintf("App:%s", w.App.String()))
}
if len(parts) == 0 {
return "{}"
}
return fmt.Sprintf("{%s}", strings.Join(parts, " "))
}

// String formats ManifestWatchOpts for debug outputs
func (m ManifestWatchOpts) String() string {
return fmt.Sprintf("{Paths:%v FilterRegex:%s}", m.Paths, m.FilterRegex)
}

// String formats AppWatchOpts for debug outputs
func (a AppWatchOpts) String() string {
return fmt.Sprintf("{Paths:%v FilterRegex:%s}", a.Paths, a.FilterRegex)
}
191 changes: 191 additions & 0 deletions internal/hooks/sdk_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,194 @@ func Test_WatchOpts_IsAvailable(t *testing.T) {
})
}
}

func Test_WatchOpts_GetManifestWatchConfig(t *testing.T) {
tests := map[string]struct {
watchOpts *WatchOpts
expectedPaths []string
expectedRegex string
expectedEnabled bool
}{
"Nil WatchOpts pointer": {
watchOpts: nil,
expectedPaths: nil,
expectedRegex: "",
expectedEnabled: false,
},
"Nested manifest config": {
watchOpts: &WatchOpts{
Manifest: &ManifestWatchOpts{
Paths: []string{"manifest.json", "workflows/"},
FilterRegex: "\\.json$",
},
},
expectedPaths: []string{"manifest.json", "workflows/"},
expectedRegex: "\\.json$",
expectedEnabled: true,
},
"Legacy flat config": {
watchOpts: &WatchOpts{
Paths: []string{"manifest.json", "src/"},
FilterRegex: "\\.(json|ts)$",
},
expectedPaths: []string{"manifest.json", "src/"},
expectedRegex: "\\.(json|ts)$",
expectedEnabled: true,
},
"Nested config takes precedence over legacy": {
watchOpts: &WatchOpts{
Paths: []string{"old-path/"},
FilterRegex: "old-regex",
Manifest: &ManifestWatchOpts{
Paths: []string{"new-path/"},
FilterRegex: "new-regex",
},
},
expectedPaths: []string{"new-path/"},
expectedRegex: "new-regex",
expectedEnabled: true,
},
"Empty nested manifest config": {
watchOpts: &WatchOpts{
Manifest: &ManifestWatchOpts{
Paths: []string{},
},
},
expectedPaths: []string{},
expectedRegex: "",
expectedEnabled: false,
},
"Empty legacy config": {
watchOpts: &WatchOpts{
Paths: []string{},
},
expectedPaths: []string{},
expectedRegex: "",
expectedEnabled: false,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
paths, regex, enabled := tt.watchOpts.GetManifestWatchConfig()
assert.Equal(t, tt.expectedPaths, paths)
assert.Equal(t, tt.expectedRegex, regex)
assert.Equal(t, tt.expectedEnabled, enabled)
})
}
}

func Test_WatchOpts_GetAppWatchConfig(t *testing.T) {
tests := map[string]struct {
watchOpts *WatchOpts
expectedPaths []string
expectedRegex string
expectedEnabled bool
}{
"Nil WatchOpts pointer": {
watchOpts: nil,
expectedPaths: nil,
expectedRegex: "",
expectedEnabled: false,
},
"Nested app config": {
watchOpts: &WatchOpts{
App: &AppWatchOpts{
Paths: []string{"src/", "functions/"},
FilterRegex: "\\.(ts|js)$",
},
},
expectedPaths: []string{"src/", "functions/"},
expectedRegex: "\\.(ts|js)$",
expectedEnabled: true,
},
"Legacy config does not enable app watching": {
watchOpts: &WatchOpts{
Paths: []string{"manifest.json", "src/"},
FilterRegex: "\\.(json|ts)$",
},
expectedPaths: nil,
expectedRegex: "",
expectedEnabled: false,
},
"Empty nested app config": {
watchOpts: &WatchOpts{
App: &AppWatchOpts{
Paths: []string{},
},
},
expectedPaths: []string{},
expectedRegex: "",
expectedEnabled: false,
},
"Nil app config": {
watchOpts: &WatchOpts{},
expectedPaths: nil,
expectedRegex: "",
expectedEnabled: false,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
paths, regex, enabled := tt.watchOpts.GetAppWatchConfig()
assert.Equal(t, tt.expectedPaths, paths)
assert.Equal(t, tt.expectedRegex, regex)
assert.Equal(t, tt.expectedEnabled, enabled)
})
}
}

func Test_WatchOpts_String(t *testing.T) {
tests := map[string]struct {
watchOpts WatchOpts
expectedString string
}{
"Nested config with both manifest and app": {
watchOpts: WatchOpts{
Manifest: &ManifestWatchOpts{
Paths: []string{"manifest.json"},
FilterRegex: "\\.json$",
},
App: &AppWatchOpts{
Paths: []string{"src/", "functions/"},
FilterRegex: "\\.(ts|js)$",
},
},
expectedString: "{Manifest:{Paths:[manifest.json] FilterRegex:\\.json$} App:{Paths:[src/ functions/] FilterRegex:\\.(ts|js)$}}",
},
"Nested manifest only": {
watchOpts: WatchOpts{
Manifest: &ManifestWatchOpts{
Paths: []string{"manifest.json"},
FilterRegex: "\\.json$",
},
},
expectedString: "{Manifest:{Paths:[manifest.json] FilterRegex:\\.json$}}",
},
"Nested app only": {
watchOpts: WatchOpts{
App: &AppWatchOpts{
Paths: []string{"src/"},
FilterRegex: "\\.(ts|js)$",
},
},
expectedString: "{App:{Paths:[src/] FilterRegex:\\.(ts|js)$}}",
},
"Legacy config": {
watchOpts: WatchOpts{
Paths: []string{"manifest.json", "src/"},
FilterRegex: "\\.(json|ts)$",
},
expectedString: "{Paths:[manifest.json src/] FilterRegex:\\.(json|ts)$}",
},
"Empty config": {
watchOpts: WatchOpts{},
expectedString: "{}",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
result := tt.watchOpts.String()
assert.Equal(t, tt.expectedString, result)
})
}
}
Loading
Loading