Skip to content

Commit 58e107c

Browse files
committed
feat: add two-way manifest sync between project and app settings
When the manifest-sync experiment is enabled, the CLI detects per-field differences between the local manifest.json and the remote app settings, lets the user resolve them interactively, and writes the merged result to both sides. This replaces the binary "overwrite?" prompt when divergence is detected during install/deploy. Adds `slack manifest sync` command and `slack sync` alias. Gated behind --experiment=manifest-sync.
1 parent f2e54bc commit 58e107c

15 files changed

Lines changed: 1248 additions & 3 deletions

File tree

cmd/manifest/manifest.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
7979

8080
// Add child commands
8181
cmd.AddCommand(NewInfoCommand(clients))
82+
cmd.AddCommand(NewSyncCommand(clients))
8283
cmd.AddCommand(NewValidateCommand(clients))
8384

8485
cmd.Flags().StringVar(

cmd/manifest/sync.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package manifest
2+
3+
import (
4+
"github.com/opentracing/opentracing-go"
5+
"github.com/slackapi/slack-cli/internal/app"
6+
"github.com/slackapi/slack-cli/internal/cmdutil"
7+
"github.com/slackapi/slack-cli/internal/pkg/manifest"
8+
"github.com/slackapi/slack-cli/internal/prompts"
9+
"github.com/slackapi/slack-cli/internal/shared"
10+
"github.com/slackapi/slack-cli/internal/style"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var manifestSyncFunc = manifest.Sync
15+
16+
func NewSyncCommand(clients *shared.ClientFactory) *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "sync",
19+
Short: "Sync the app manifest between project and app settings",
20+
Long: "Compare the local project manifest with app settings, resolve differences, and sync both to the same state.",
21+
Example: style.ExampleCommandsf([]style.ExampleCommand{
22+
{Command: "manifest sync", Meaning: "Sync project manifest with app settings"},
23+
}),
24+
Args: cobra.NoArgs,
25+
PreRunE: func(cmd *cobra.Command, args []string) error {
26+
return cmdutil.IsValidProjectDirectory(clients)
27+
},
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
ctx := cmd.Context()
30+
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.manifest.sync")
31+
defer span.Finish()
32+
33+
selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
34+
if err != nil {
35+
return err
36+
}
37+
38+
clients.Config.ManifestEnv = app.SetManifestEnvTeamVars(clients.Config.ManifestEnv, selection.App.TeamDomain, selection.App.IsDev)
39+
40+
_, err = manifestSyncFunc(ctx, clients, selection.App, selection.Auth)
41+
return err
42+
},
43+
}
44+
return cmd
45+
}

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ var AliasMap = map[string]*AliasInfo{
8080
"logout": {CommandFactory: auth.NewLogoutCommand, CanonicalName: "auth logout", ParentName: "auth"},
8181
"run": {CommandFactory: platform.NewRunCommand, CanonicalName: "platform run", ParentName: "platform"},
8282
"samples": {CommandFactory: project.NewSamplesCommand, CanonicalName: "project samples", ParentName: "project"},
83+
"sync": {CommandFactory: manifest.NewSyncCommand, CanonicalName: "manifest sync", ParentName: "manifest"},
8384
"uninstall": {CommandFactory: app.NewUninstallCommand, CanonicalName: "app uninstall", ParentName: "app"},
8485
}
8586
var processName = cmdutil.GetProcessName()

internal/experiment/experiment.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const (
3333
// Lipgloss experiment shows pretty styles.
3434
Lipgloss Experiment = "lipgloss"
3535

36+
// ManifestSync experiment enables two-way manifest sync between local and remote.
37+
ManifestSync Experiment = "manifest-sync"
38+
3639
// Placeholder experiment is a placeholder for testing and does nothing... or does it?
3740
Placeholder Experiment = "placeholder"
3841

@@ -47,6 +50,7 @@ const (
4750
// Please also add here 👇
4851
var AllExperiments = []Experiment{
4952
Lipgloss,
53+
ManifestSync,
5054
Placeholder,
5155
Sandboxes,
5256
SetIcon,

internal/pkg/apps/install.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
"github.com/slackapi/slack-cli/internal/config"
2626
"github.com/slackapi/slack-cli/internal/experiment"
2727
"github.com/slackapi/slack-cli/internal/icon"
28-
"github.com/slackapi/slack-cli/internal/pkg/manifest"
28+
manifestpkg "github.com/slackapi/slack-cli/internal/pkg/manifest"
2929
"github.com/slackapi/slack-cli/internal/shared"
3030
"github.com/slackapi/slack-cli/internal/shared/types"
3131
"github.com/slackapi/slack-cli/internal/slackerror"
@@ -273,11 +273,11 @@ func printNonSuccessInstallState(ctx context.Context, clients *shared.ClientFact
273273
func validateManifestForInstall(ctx context.Context, clients *shared.ClientFactory, token string, app types.App, appManifest types.AppManifest) error {
274274
validationResult, err := clients.API().ValidateAppManifest(ctx, token, appManifest, app.AppID)
275275

276-
if retryValidate := manifest.HandleConnectorNotInstalled(ctx, clients, token, err); retryValidate {
276+
if retryValidate := manifestpkg.HandleConnectorNotInstalled(ctx, clients, token, err); retryValidate {
277277
validationResult, err = clients.API().ValidateAppManifest(ctx, token, appManifest, app.AppID)
278278
}
279279

280-
if err := manifest.HandleConnectorApprovalRequired(ctx, clients, token, err); err != nil {
280+
if err := manifestpkg.HandleConnectorApprovalRequired(ctx, clients, token, err); err != nil {
281281
return err
282282
}
283283

@@ -764,6 +764,15 @@ func shouldUpdateManifest(ctx context.Context, clients *shared.ClientFactory, ap
764764
default:
765765
notice = style.Yellow("The manifest on app settings has been changed since last update")
766766
}
767+
768+
if clients.Config.WithExperimentOn(experiment.ManifestSync) {
769+
_, err := manifestpkg.Sync(ctx, clients, app, auth)
770+
if err != nil {
771+
return false, err
772+
}
773+
return false, nil
774+
}
775+
767776
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
768777
Emoji: "books",
769778
Text: "App Manifest",

internal/pkg/manifest/diff.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package manifest
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/slackapi/slack-cli/internal/shared/types"
8+
)
9+
10+
// DiffType describes how a field differs between local and remote.
11+
type DiffType int
12+
13+
const (
14+
DiffModified DiffType = iota // Both sides have the field but with different values
15+
DiffLocalOnly // Field exists only in local (added locally or deleted remotely)
16+
DiffRemoteOnly // Field exists only in remote (added remotely or deleted locally)
17+
)
18+
19+
// FieldDiff represents a single difference between local and remote manifests.
20+
type FieldDiff struct {
21+
Path string
22+
Type DiffType
23+
LocalValue any
24+
RemoteValue any
25+
}
26+
27+
// DiffResult holds all differences found between two manifests.
28+
type DiffResult struct {
29+
Diffs []FieldDiff
30+
}
31+
32+
// HasDifferences returns true if any differences were found.
33+
func (dr *DiffResult) HasDifferences() bool {
34+
return len(dr.Diffs) > 0
35+
}
36+
37+
// Diff performs a two-way comparison between local and remote manifests,
38+
// returning all fields that differ between them.
39+
func Diff(local, remote types.AppManifest) (*DiffResult, error) {
40+
localFlat, err := Flatten(local)
41+
if err != nil {
42+
return nil, fmt.Errorf("failed to flatten local manifest: %w", err)
43+
}
44+
remoteFlat, err := Flatten(remote)
45+
if err != nil {
46+
return nil, fmt.Errorf("failed to flatten remote manifest: %w", err)
47+
}
48+
return diffFlat(localFlat, remoteFlat), nil
49+
}
50+
51+
func diffFlat(local, remote map[string]any) *DiffResult {
52+
result := &DiffResult{}
53+
seen := make(map[string]bool)
54+
55+
for path, localVal := range local {
56+
seen[path] = true
57+
remoteVal, exists := remote[path]
58+
if !exists {
59+
result.Diffs = append(result.Diffs, FieldDiff{
60+
Path: path,
61+
Type: DiffLocalOnly,
62+
LocalValue: localVal,
63+
})
64+
continue
65+
}
66+
if !valuesEqual(localVal, remoteVal) {
67+
result.Diffs = append(result.Diffs, FieldDiff{
68+
Path: path,
69+
Type: DiffModified,
70+
LocalValue: localVal,
71+
RemoteValue: remoteVal,
72+
})
73+
}
74+
}
75+
76+
for path, remoteVal := range remote {
77+
if seen[path] {
78+
continue
79+
}
80+
result.Diffs = append(result.Diffs, FieldDiff{
81+
Path: path,
82+
Type: DiffRemoteOnly,
83+
RemoteValue: remoteVal,
84+
})
85+
}
86+
87+
return result
88+
}
89+
90+
func valuesEqual(a, b any) bool {
91+
aJSON, err := json.Marshal(a)
92+
if err != nil {
93+
return false
94+
}
95+
bJSON, err := json.Marshal(b)
96+
if err != nil {
97+
return false
98+
}
99+
return string(aJSON) == string(bJSON)
100+
}

internal/pkg/manifest/diff_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package manifest
2+
3+
import (
4+
"testing"
5+
6+
"github.com/slackapi/slack-cli/internal/shared/types"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func Test_Diff(t *testing.T) {
12+
tests := map[string]struct {
13+
local types.AppManifest
14+
remote types.AppManifest
15+
expected []FieldDiff
16+
}{
17+
"identical manifests produce no diffs": {
18+
local: types.AppManifest{
19+
DisplayInformation: types.DisplayInformation{Name: "App"},
20+
},
21+
remote: types.AppManifest{
22+
DisplayInformation: types.DisplayInformation{Name: "App"},
23+
},
24+
expected: nil,
25+
},
26+
"modified field detected": {
27+
local: types.AppManifest{
28+
DisplayInformation: types.DisplayInformation{Name: "App", Description: "Local desc"},
29+
},
30+
remote: types.AppManifest{
31+
DisplayInformation: types.DisplayInformation{Name: "App", Description: "Remote desc"},
32+
},
33+
expected: []FieldDiff{
34+
{Path: "display_information.description", Type: DiffModified, LocalValue: "Local desc", RemoteValue: "Remote desc"},
35+
},
36+
},
37+
"local-only field detected": {
38+
local: types.AppManifest{
39+
DisplayInformation: types.DisplayInformation{Name: "App", Description: "Has desc"},
40+
},
41+
remote: types.AppManifest{
42+
DisplayInformation: types.DisplayInformation{Name: "App"},
43+
},
44+
expected: []FieldDiff{
45+
{Path: "display_information.description", Type: DiffLocalOnly, LocalValue: "Has desc"},
46+
},
47+
},
48+
"remote-only field detected": {
49+
local: types.AppManifest{
50+
DisplayInformation: types.DisplayInformation{Name: "App"},
51+
},
52+
remote: types.AppManifest{
53+
DisplayInformation: types.DisplayInformation{Name: "App", Description: "Remote only"},
54+
},
55+
expected: []FieldDiff{
56+
{Path: "display_information.description", Type: DiffRemoteOnly, RemoteValue: "Remote only"},
57+
},
58+
},
59+
"function added locally": {
60+
local: types.AppManifest{
61+
DisplayInformation: types.DisplayInformation{Name: "App"},
62+
Functions: map[string]types.ManifestFunction{
63+
"greet": {Title: "Greet", Description: "Hello"},
64+
},
65+
},
66+
remote: types.AppManifest{
67+
DisplayInformation: types.DisplayInformation{Name: "App"},
68+
},
69+
expected: []FieldDiff{
70+
{Path: "functions.greet.description", Type: DiffLocalOnly, LocalValue: "Hello"},
71+
{Path: "functions.greet.title", Type: DiffLocalOnly, LocalValue: "Greet"},
72+
},
73+
},
74+
"array values compared as wholes": {
75+
local: types.AppManifest{
76+
DisplayInformation: types.DisplayInformation{Name: "App"},
77+
OAuthConfig: &types.OAuthConfig{
78+
Scopes: &types.ManifestScopes{
79+
Bot: []string{"chat:write", "users:read"},
80+
},
81+
},
82+
},
83+
remote: types.AppManifest{
84+
DisplayInformation: types.DisplayInformation{Name: "App"},
85+
OAuthConfig: &types.OAuthConfig{
86+
Scopes: &types.ManifestScopes{
87+
Bot: []string{"chat:write", "files:read"},
88+
},
89+
},
90+
},
91+
expected: []FieldDiff{
92+
{
93+
Path: "oauth_config.scopes.bot",
94+
Type: DiffModified,
95+
LocalValue: []any{"chat:write", "users:read"},
96+
RemoteValue: []any{"chat:write", "files:read"},
97+
},
98+
},
99+
},
100+
}
101+
for name, tc := range tests {
102+
t.Run(name, func(t *testing.T) {
103+
result, err := Diff(tc.local, tc.remote)
104+
require.NoError(t, err)
105+
if tc.expected == nil {
106+
assert.False(t, result.HasDifferences())
107+
return
108+
}
109+
assert.True(t, result.HasDifferences())
110+
for _, expectedDiff := range tc.expected {
111+
found := false
112+
for _, actualDiff := range result.Diffs {
113+
if actualDiff.Path == expectedDiff.Path {
114+
found = true
115+
assert.Equal(t, expectedDiff.Type, actualDiff.Type, "diff type mismatch for path %s", expectedDiff.Path)
116+
if expectedDiff.LocalValue != nil {
117+
assert.Equal(t, expectedDiff.LocalValue, actualDiff.LocalValue, "local value mismatch for path %s", expectedDiff.Path)
118+
}
119+
if expectedDiff.RemoteValue != nil {
120+
assert.Equal(t, expectedDiff.RemoteValue, actualDiff.RemoteValue, "remote value mismatch for path %s", expectedDiff.Path)
121+
}
122+
break
123+
}
124+
}
125+
assert.True(t, found, "expected diff not found for path %s", expectedDiff.Path)
126+
}
127+
})
128+
}
129+
}
130+
131+
func Test_DiffResult_HasDifferences(t *testing.T) {
132+
t.Run("empty result has no differences", func(t *testing.T) {
133+
result := &DiffResult{}
134+
assert.False(t, result.HasDifferences())
135+
})
136+
137+
t.Run("result with diffs has differences", func(t *testing.T) {
138+
result := &DiffResult{
139+
Diffs: []FieldDiff{{Path: "test", Type: DiffModified}},
140+
}
141+
assert.True(t, result.HasDifferences())
142+
})
143+
}

0 commit comments

Comments
 (0)