Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ slck apps delete A0123456789
|---------|-------|-------------|
| `get <app-id>` | `--to-file` | Print an app's manifest as indented JSON with sorted keys |
| `validate` | `--file`, `--app` | Check a manifest with `apps.manifest.validate` |
| `diff <app-id>` | `--file` | Compare the live manifest with a file, one line per difference |
| `diff <app-id>` | `--file` | Compare the live manifest with a file, one line per difference; settings Slack fills in with false or empty defaults are not differences |
| `create` | `--file`, `--credentials-file` | Create an app; prints its ID and install URL |
| `update <app-id>` | `--file` | Replace an app's manifest; says when a reinstall is needed |
| `delete <app-id>` | `--force` | Permanently delete an app |
Expand Down
34 changes: 34 additions & 0 deletions internal/cmd/apps/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,40 @@ func TestDiffExitsOneAndListsDifferences(t *testing.T) {
`, out.String())
}

func TestDiffIgnoresDefaultsTheFileLeavesOut(t *testing.T) {
_, api := newFakeSlack(t, map[string][]string{"apps.manifest.export": {"manifest-export-with-defaults.json"}})
out := captureOutput(t)

err := runDiff(newTestSession(api, storedPair(t, now.Add(time.Hour))), "A0TESTAPP01", "testdata/manifest.json")

var exit *exitError
require.True(t, errors.As(err, &exit))
assert.Equal(t, 1, exit.ExitCode())
assert.Equal(t, "- /settings/interactivity: {\"is_enabled\":true} (live only)\n", out.String(),
"false defaults Slack adds on export are not differences; a live-only setting that is on still is")
}

func TestIsDefault(t *testing.T) {
cases := map[string]bool{
`null`: true,
`false`: true,
`""`: true,
`[]`: true,
`{}`: true,
`{"a":false,"b":{"c":[]}}`: true,
`true`: false,
`0`: false,
`"x"`: false,
`["chat:write"]`: false,
`{"a":false,"b":{"c":true}}`: false,
}
for raw, want := range cases {
var v any
require.NoError(t, json.Unmarshal([]byte(raw), &v))
assert.Equal(t, want, isDefault(v), raw)
}
}

func TestDiffExitsTwoWhenItCannotCompare(t *testing.T) {
_, api := newFakeSlack(t, map[string][]string{})
captureOutput(t)
Expand Down
29 changes: 28 additions & 1 deletion internal/cmd/apps/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ type change struct {

// diffManifests lists the differences between two manifests, sorted by
// pointer. Objects are compared key by key; arrays and scalars are compared
// as whole values, so a reordered scope list counts as a change.
// as whole values, so a reordered scope list counts as a change. A key on one
// side only whose value is false, empty or null is not a difference: Slack
// exports the defaults of settings a manifest leaves out, such as
// "pkce_enabled": false, so a freshly created app would otherwise never match
// its own file.
func diffManifests(live, file json.RawMessage) ([]change, error) {
var l, f any
if err := json.Unmarshal(live, &l); err != nil {
Expand Down Expand Up @@ -56,6 +60,7 @@ func walk(ptr string, live, file any, out *[]change) {
lv, inLive := lm[k]
fv, inFile := fm[k]
switch {
case (!inFile && isDefault(lv)) || (!inLive && isDefault(fv)):
case !inFile:
*out = append(*out, change{Pointer: child, Live: lv, Side: "live"})
case !inLive:
Expand All @@ -66,6 +71,28 @@ func walk(ptr string, live, file any, out *[]change) {
}
}

// isDefault reports whether v is the value Slack assumes for an omitted key.
func isDefault(v any) bool {
Comment thread
piekstra marked this conversation as resolved.
switch t := v.(type) {
case nil:
return true
case bool:
return !t
case string:
return t == ""
case []any:
return len(t) == 0
case map[string]any:
for _, child := range t {
if !isDefault(child) {
return false
}
}
return true
}
return false
}

// escapePointer escapes a key for a JSON pointer (RFC 6901).
func escapePointer(k string) string {
return strings.ReplaceAll(strings.ReplaceAll(k, "~", "~0"), "/", "~1")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"ok":true,"manifest":{"display_information":{"name":"Example Bot","description":"Posts build results."},"features":{"bot_user":{"display_name":"Example Bot","always_online":false}},"oauth_config":{"scopes":{"bot":["chat:write","channels:read"]},"pkce_enabled":false},"settings":{"org_deploy_enabled":false,"socket_mode_enabled":false,"token_rotation_enabled":false,"app_level_token_rotation_enabled":false,"is_mcp_enabled":false,"interactivity":{"is_enabled":true}}}}
Loading