Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fb3aa95
feat: add slack manifest diff command
mwbrooks Jun 13, 2026
1a99403
docs: add CI/CD example for manifest diff
mwbrooks Jun 13, 2026
5e518f0
docs: reword non-interactive manifest diff example
mwbrooks Jun 13, 2026
79d0468
docs: add godoc for NewDiffCommand
mwbrooks Jun 14, 2026
2a8816b
docs: add godoc for unexported manifest helpers
mwbrooks Jun 14, 2026
d58fd8e
fix: drop extra blank line before the first manifest diff entry
mwbrooks Jun 14, 2026
db6aa47
fix: filter _metadata paths out of manifest diff output
mwbrooks Jun 14, 2026
1e70330
fix: truncate manifest diff values by rune, not byte
mwbrooks Jun 14, 2026
ea65069
test: assert exact diff and flatten counts to catch spurious entries
mwbrooks Jun 14, 2026
9cab9ad
fix: suppress " (local)" suffix differences in manifest diff
mwbrooks Jun 14, 2026
413f272
fix: suppress remote-only is_mcp_enabled=false from manifest diff
mwbrooks Jun 14, 2026
d1cf988
docs: reword in-sync message to "match"
mwbrooks Jun 15, 2026
79d3a36
feat: render manifest diff side-by-side for all difference types
mwbrooks Jun 15, 2026
8370ed7
docs: pluralize manifest diff count using style.Pluralize
mwbrooks Jun 15, 2026
e585fa9
test: raise display.go coverage above 80%
mwbrooks Jun 15, 2026
2093f53
refactor: rename display.go to diff_display.go
mwbrooks Jun 15, 2026
0395695
Merge branch 'main' into mwbrooks-manifest-diff
mwbrooks Jul 16, 2026
77be121
fix: address PR review feedback from zimeg
srtaalej Jul 17, 2026
efa30b4
refactor: address additional PR review feedback for manifest diff
srtaalej Jul 17, 2026
2bc5fd9
Merge branch 'main' into mwbrooks-manifest-diff
srtaalej Jul 17, 2026
690740c
refactor: move display logic from internal/manifest to cmd/manifest
srtaalej Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions cmd/manifest/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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 manifest

import (
"github.com/opentracing/opentracing-go"
"github.com/slackapi/slack-cli/internal/app"
"github.com/slackapi/slack-cli/internal/cmdutil"
"github.com/slackapi/slack-cli/internal/manifest"
"github.com/slackapi/slack-cli/internal/prompts"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

// NewDiffCommand implements the "manifest diff" command, which prints the
// differences between the project manifest and the app settings on Slack.
func NewDiffCommand(clients *shared.ClientFactory) *cobra.Command {
return &cobra.Command{
Use: "diff",
Short: "Show differences between the project manifest and app settings",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

note: I feel we should say App Settings Slack API, but the CLI currently references App Settings, so I thought I'd stay consistent.

Long: "Compare the project manifest with app settings and print any differences.",
Example: style.ExampleCommandsf([]style.ExampleCommand{
{Command: "manifest diff", Meaning: "Show differences between project manifest and app settings"},
{Command: "manifest diff --app A0123456789 --token xoxp-...", Meaning: "Show manifest differences without prompts"},
}),
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.manifest.diff")
defer span.Finish()

selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
if err != nil {
return err
}

clients.Config.ManifestEnv = app.SetManifestEnvTeamVars(clients.Config.ManifestEnv, selection.App.TeamDomain, selection.App.IsDev)

localManifest, err := clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor)
if err != nil {
return err
}

remoteManifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, selection.Auth.Token, selection.App.AppID)
if err != nil {
return err
}

diffs, err := manifest.Diff(localManifest.AppManifest, remoteManifest.AppManifest, selection.App.IsDev)
if err != nil {
return err
}

if !diffs.HasDifferences() {
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Text: "Manifest Diff",
Secondary: []string{"Project manifest and app settings match"},
}))
return nil

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

note: We don't return a non-zero exit code when there is a manifest diff. Ideally, we should. However, manifest validate does not return a non-zero exist code on when the manifest is invalid and the CLI plumbing would surface a visual error to interactive users. So, we should have a follow-up task to standardize non-zero exit code handling.

}

displayDiffs(ctx, clients.IO, diffs)
return nil
},
}
}
86 changes: 86 additions & 0 deletions cmd/manifest/diff_display.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// 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 manifest

import (
"context"
"encoding/json"
"fmt"
"sort"

"github.com/slackapi/slack-cli/internal/iostreams"
internalmanifest "github.com/slackapi/slack-cli/internal/manifest"
"github.com/slackapi/slack-cli/internal/style"
)

func displayDiffs(ctx context.Context, io iostreams.IOStreamer, diffs *internalmanifest.DiffResult) {
if !diffs.HasDifferences() {
return
}

sorted := make([]internalmanifest.FieldDiff, len(diffs.Diffs))
copy(sorted, diffs.Diffs)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Path < sorted[j].Path
})

io.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Text: "Manifest Diff",
Secondary: []string{
fmt.Sprintf("Found %d %s between project and app settings", len(sorted), style.Pluralize("difference", "differences", len(sorted))),
},
}))

for i, d := range sorted {
if i > 0 {
io.PrintInfo(ctx, false, "")
}
var local, remote string
switch d.Type {
case internalmanifest.DiffLocalOnly:
local = formatValue(d.LocalValue)
remote = absentValue
case internalmanifest.DiffRemoteOnly:
local = absentValue
remote = formatValue(d.RemoteValue)
default:
local = formatValue(d.LocalValue)
remote = formatValue(d.RemoteValue)
}
io.PrintInfo(ctx, false, " %s", d.Path)
io.PrintInfo(ctx, false, " Project: %s", local)
io.PrintInfo(ctx, false, " App settings: %s", remote)
}
io.PrintInfo(ctx, false, "")
}

const absentValue = "(not set)"

func formatValue(v any) string {
if v == nil {
return "(not present)"
}
switch val := v.(type) {
case string:
return fmt.Sprintf("%q", val)
default:
data, err := json.Marshal(val)
if err != nil {
return fmt.Sprintf("%v", val)
}
return style.TruncateRunes(string(data), 80)
}
}
148 changes: 148 additions & 0 deletions cmd/manifest/diff_display_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// 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 manifest

import (
"testing"

internalmanifest "github.com/slackapi/slack-cli/internal/manifest"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/stretchr/testify/assert"
)

func Test_displayDiffs(t *testing.T) {
tests := map[string]struct {
diffs []internalmanifest.FieldDiff
expectedSubstrs []string
forbiddenSubstrs []string
}{
"modified field shows both values side-by-side": {
diffs: []internalmanifest.FieldDiff{
{
Path: "display_information.name",
Type: internalmanifest.DiffModified,
LocalValue: "Project Name",
RemoteValue: "Remote Name",
},
},
expectedSubstrs: []string{
"display_information.name",
`Project: "Project Name"`,
`App settings: "Remote Name"`,
},
forbiddenSubstrs: []string{"(only in", "Value:", "(not set)"},
},
"local-only field shows (not set) on the app settings side": {
diffs: []internalmanifest.FieldDiff{
{
Path: "functions.greet.title",
Type: internalmanifest.DiffLocalOnly,
LocalValue: "Greet",
},
},
expectedSubstrs: []string{
"functions.greet.title",
`Project: "Greet"`,
"App settings: (not set)",
},
forbiddenSubstrs: []string{"(only in", "Value:"},
},
"remote-only field shows (not set) on the project side": {
diffs: []internalmanifest.FieldDiff{
{
Path: "settings.is_mcp_enabled",
Type: internalmanifest.DiffRemoteOnly,
RemoteValue: false,
},
},
expectedSubstrs: []string{
"settings.is_mcp_enabled",
"Project: (not set)",
"App settings: false",
},
forbiddenSubstrs: []string{"(only in", "Value:"},
},
"multiple diffs are sorted by path and rendered in order": {
diffs: []internalmanifest.FieldDiff{
{Path: "features.bot_user.display_name", Type: internalmanifest.DiffModified, LocalValue: "App", RemoteValue: "App (local)"},
{Path: "display_information.name", Type: internalmanifest.DiffModified, LocalValue: "App", RemoteValue: "App (local)"},
},
expectedSubstrs: []string{
"Found 2 differences between project and app settings",
"display_information.name",
"features.bot_user.display_name",
},
},
"empty result prints nothing": {
diffs: nil,
forbiddenSubstrs: []string{
"Found",
"App Manifest",
"Project:",
"App settings:",
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
cm := shared.NewClientsMock()
cm.AddDefaultMocks()

displayDiffs(ctx, cm.IO, &internalmanifest.DiffResult{Diffs: tc.diffs})

out := cm.GetStdoutOutput()
for _, want := range tc.expectedSubstrs {
assert.Contains(t, out, want, "expected output to contain %q", want)
}
for _, forbidden := range tc.forbiddenSubstrs {
assert.NotContains(t, out, forbidden, "expected output not to contain %q", forbidden)
}
})
}
}

func Test_formatValue(t *testing.T) {
tests := map[string]struct {
input any
expected string
}{
"nil renders as (not present)": {
input: nil,
expected: "(not present)",
},
"strings are quoted": {
input: "hello",
expected: `"hello"`,
},
"booleans are JSON-encoded": {
input: false,
expected: "false",
},
"long non-string values are rune-truncated": {
input: []string{
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot",
"golf", "hotel", "india", "juliet", "kilo",
},
expected: `["alpha","bravo","charlie","delta","echo","foxtrot","golf","hotel","india","j...`,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.expected, formatValue(tc.input))
})
}
}
Loading
Loading