diff --git a/README.md b/README.md index 0a52878..22accf3 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ slck apps delete A0123456789 |---------|-------|-------------| | `get ` | `--to-file` | Print an app's manifest as indented JSON with sorted keys | | `validate` | `--file`, `--app` | Check a manifest with `apps.manifest.validate` | -| `diff ` | `--file` | Compare the live manifest with a file, one line per difference | +| `diff ` | `--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 ` | `--file` | Replace an app's manifest; says when a reinstall is needed | | `delete ` | `--force` | Permanently delete an app | diff --git a/internal/cmd/apps/apps_test.go b/internal/cmd/apps/apps_test.go index a0351c3..d8f2039 100644 --- a/internal/cmd/apps/apps_test.go +++ b/internal/cmd/apps/apps_test.go @@ -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) diff --git a/internal/cmd/apps/diff.go b/internal/cmd/apps/diff.go index 3efba6b..16deaa6 100644 --- a/internal/cmd/apps/diff.go +++ b/internal/cmd/apps/diff.go @@ -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 { @@ -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: @@ -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 { + 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") diff --git a/internal/cmd/apps/testdata/manifest-export-with-defaults.json b/internal/cmd/apps/testdata/manifest-export-with-defaults.json new file mode 100644 index 0000000..6cd7848 --- /dev/null +++ b/internal/cmd/apps/testdata/manifest-export-with-defaults.json @@ -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}}}}