Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions cmd/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {

// Add child commands
cmd.AddCommand(NewInfoCommand(clients))
cmd.AddCommand(NewSyncCommand(clients))
cmd.AddCommand(NewValidateCommand(clients))

cmd.Flags().StringVar(
Expand Down
69 changes: 69 additions & 0 deletions cmd/manifest/sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// 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/experiment"
"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/slackerror"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

var manifestSyncFunc = manifest.Sync

func NewSyncCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "sync",
Short: "Sync the app manifest between project and app settings",
Long: "Compare the local project manifest with app settings, resolve differences, and sync both to the same state.",
Hidden: true,
Example: style.ExampleCommandsf([]style.ExampleCommand{
{Command: "manifest sync", Meaning: "Sync project manifest with app settings"},
}),
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if !clients.Config.WithExperimentOn(experiment.ManifestSync) {
return slackerror.New(slackerror.ErrExperimentRequired).
WithRemediation("Enable the %s experiment with %s",
style.Highlight(string(experiment.ManifestSync)),
style.CommandText("--experiment manifest-sync"),
)
}
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.manifest.sync")
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)

_, err = manifestSyncFunc(ctx, clients, selection.App, selection.Auth)
return err
},
}
return cmd
}
51 changes: 51 additions & 0 deletions cmd/manifest/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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"
"testing"

"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/test/testutil"
"github.com/spf13/cobra"
)

func TestSyncCommand(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"errors when the manifest-sync experiment is off": {
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.AddDefaultMocks()
cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug)
},
ExpectedError: slackerror.New(slackerror.ErrExperimentRequired),
},
"passes the experiment gate when manifest-sync is enabled via flag": {
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
cm.AddDefaultMocks()
cf.Config.ExperimentsFlag = []string{string(experiment.ManifestSync)}
cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug)
},
// We expect the command to fail downstream of the gate (no app
// selected, no SDK config), but NOT with ErrCommandUnavailable —
// the gate itself should pass.
ExpectedErrorStrings: []string{},
},
}, func(clients *shared.ClientFactory) *cobra.Command {
return NewSyncCommand(clients)
})
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ var AliasMap = map[string]*AliasInfo{
"logout": {CommandFactory: auth.NewLogoutCommand, CanonicalName: "auth logout", ParentName: "auth"},
"run": {CommandFactory: platform.NewRunCommand, CanonicalName: "platform run", ParentName: "platform"},
"samples": {CommandFactory: project.NewSamplesCommand, CanonicalName: "project samples", ParentName: "project"},
"sync": {CommandFactory: manifest.NewSyncCommand, CanonicalName: "manifest sync", ParentName: "manifest"},
"uninstall": {CommandFactory: app.NewUninstallCommand, CanonicalName: "app uninstall", ParentName: "app"},
}
var processName = cmdutil.GetProcessName()
Expand Down
4 changes: 4 additions & 0 deletions internal/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const (
// Lipgloss experiment shows pretty styles.
Lipgloss Experiment = "lipgloss"

// ManifestSync experiment enables two-way manifest sync between local and remote.
ManifestSync Experiment = "manifest-sync"

// Placeholder experiment is a placeholder for testing and does nothing... or does it?
Placeholder Experiment = "placeholder"

Expand All @@ -44,6 +47,7 @@ const (
// Please also add here 👇
var AllExperiments = []Experiment{
Lipgloss,
ManifestSync,
Placeholder,
SetIcon,
}
Expand Down
118 changes: 118 additions & 0 deletions internal/manifest/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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 (
"encoding/json"
"fmt"

"github.com/slackapi/slack-cli/internal/shared/types"
)

// DiffType describes how a field differs between local and remote.
type DiffType int

const (
DiffModified DiffType = iota // Both sides have the field but with different values
DiffLocalOnly // Field exists only in local (added locally or deleted remotely)
DiffRemoteOnly // Field exists only in remote (added remotely or deleted locally)
)

// FieldDiff represents a single difference between local and remote manifests.
type FieldDiff struct {
Path string
Type DiffType
LocalValue any
RemoteValue any
}

// DiffResult holds all differences found between two manifests.
type DiffResult struct {
Diffs []FieldDiff
}

// HasDifferences returns true if any differences were found.
func (dr *DiffResult) HasDifferences() bool {
return len(dr.Diffs) > 0
}

// Diff performs a two-way comparison between local and remote manifests,
// returning all fields that differ between them.
func Diff(local, remote types.AppManifest) (*DiffResult, error) {
localFlat, err := Flatten(local)
if err != nil {
return nil, fmt.Errorf("failed to flatten local manifest: %w", err)
}
remoteFlat, err := Flatten(remote)
if err != nil {
return nil, fmt.Errorf("failed to flatten remote manifest: %w", err)
}
return diffFlat(localFlat, remoteFlat)
}

func diffFlat(local, remote map[string]any) (*DiffResult, error) {
result := &DiffResult{}
seen := make(map[string]bool)

for path, localVal := range local {
seen[path] = true
remoteVal, exists := remote[path]
if !exists {
result.Diffs = append(result.Diffs, FieldDiff{
Path: path,
Type: DiffLocalOnly,
LocalValue: localVal,
})
continue
}
equal, err := valuesEqual(localVal, remoteVal)
if err != nil {
return nil, fmt.Errorf("failed to compare manifest values at %q: %w", path, err)
}
if !equal {
result.Diffs = append(result.Diffs, FieldDiff{
Path: path,
Type: DiffModified,
LocalValue: localVal,
RemoteValue: remoteVal,
})
}
}

for path, remoteVal := range remote {
if seen[path] {
continue
}
result.Diffs = append(result.Diffs, FieldDiff{
Path: path,
Type: DiffRemoteOnly,
RemoteValue: remoteVal,
})
}

return result, nil
}

func valuesEqual(a, b any) (bool, error) {
aJSON, err := json.Marshal(a)
if err != nil {
return false, err
}
bJSON, err := json.Marshal(b)
if err != nil {
return false, err
}
return string(aJSON) == string(bJSON), nil
}
Loading
Loading