Skip to content
Open
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
15 changes: 3 additions & 12 deletions cmd/manifest/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ 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"
Expand All @@ -31,24 +30,16 @@ 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,
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.",
Example: style.ExampleCommandsf([]style.ExampleCommand{
{Command: "manifest sync", Meaning: "Sync project manifest with app settings"},
{Command: "manifest sync --force", Meaning: "Push project manifest to app settings without prompting"},
{Command: "manifest sync --force-remote", Meaning: "Pull app settings to project manifest without prompting"},
}),
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"),
)
}
if clients.Config.ForceFlag && clients.Config.ForceRemoteFlag {
return slackerror.New(slackerror.ErrMismatchedFlags).
WithMessage("Cannot use both %s and %s flags", style.CommandText("--force"), style.CommandText("--force-remote"))
Expand Down
19 changes: 2 additions & 17 deletions cmd/manifest/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,39 +18,24 @@ 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": {
"succeeds past PreRunE with valid project": {
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.
// Command fails downstream (no app selected), but PreRunE passes.
ExpectedErrorStrings: []string{},
},
"errors when both --force and --force-remote are set": {
CmdArgs: []string{"--force-remote"},
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)
cf.Config.ForceFlag = true
},
ExpectedErrorStrings: []string{"Cannot use both", "--force", "--force-remote"},
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ The Slack CLI has an experiment (`-e`) flag behind which we put features current
The following is a list of currently available experiments. We'll remove experiments from this page if we decide they are no longer needed or once they are released, in which case we'll make an announcement about the feature's general availability in the [developer changelog](https://docs.slack.dev/changelog).

- `lipgloss`: shows pretty styles.
- `manifest-sync`: resolves conflicting app manifest values.
- `set-icon`: enables icon upload for non-hosted apps ([PR#469](https://github.com/slackapi/slack-cli/pull/469)).

## Experiments changelog

Below is a list of updates related to experiments.

- **August 2026**: Concluded the `manifest-sync` experiment with full support for two-way manifest sync now enabled by default in the Slack CLI.
- **July 2026**: Added the `manifest-sync` experiment to resolve changed app manifest values between a project and app settings.
- **April 2026**: Concluded the `sandboxes` experiment with full support in the Slack CLI. Refer to the [`slack sandbox create`](/tools/slack-cli/reference/commands/slack_sandbox_create/), [`slack sandbox delete`](/tools/slack-cli/reference/commands/slack_sandbox_delete/), and [`slack sandbox list`](/tools/slack-cli/reference/commands/slack_sandbox_list/) commands for more details.
- **April 2026**: Added the `set-icon` experiment to enable icon upload for non-hosted apps.
Expand Down
4 changes: 0 additions & 4 deletions internal/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ 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 @@ -47,7 +44,6 @@ const (
// Please also add here 👇
var AllExperiments = []Experiment{
Lipgloss,
ManifestSync,
Placeholder,
SetIcon,
}
Expand Down
63 changes: 5 additions & 58 deletions internal/pkg/apps/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const (
CreateAppManifestAndInstall = false
)

var manifestSyncFunc = manifestpkg.Sync

const additionalManifestInfoNotice = "App manifest contains some components that may require additional information"

// Install installs the app to a team
Expand Down Expand Up @@ -755,70 +757,15 @@ func shouldUpdateManifest(ctx context.Context, clients *shared.ClientFactory, ap
if err != nil {
return false, err
}
notice := ""
switch {
case saved.Equals(hash):
if saved.Equals(hash) {
return true, nil
case saved.Equals(""):
notice = "Manifest values for this app are overwritten on reinstall"
default:
notice = style.Yellow("The manifest on app settings has been changed since last update")
}

if clients.Config.WithExperimentOn(experiment.ManifestSync) {
_, err := manifestpkg.Sync(ctx, clients, app, auth)
if err != nil {
return false, err
}
return false, nil
}

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Text: "App Manifest",
Secondary: []string{notice},
}))
if !clients.IO.IsTTY() {
return false, errorAppManifestUpdate(app, true)
}
continues, err := clients.IO.ConfirmPrompt(
ctx,
"Overwrite manifest on app settings with the project's manifest file?",
false,
)
_, err = manifestSyncFunc(ctx, clients, app, auth)
if err != nil {
return false, err
}
if !continues {
return false, errorAppManifestUpdate(app, false)
}
return true, nil
}

// errorAppManifestUpdate formats an error message with app specific remediation
func errorAppManifestUpdate(app types.App, forceOption bool) *slackerror.Error {
url := "https://api.slack.com/apps"
switch {
case app.AppID != "" && app.EnterpriseID != "":
url = fmt.Sprintf("https://app.slack.com/app-settings/%s/%s/app-manifest", app.EnterpriseID, app.AppID)
case app.AppID != "" && app.TeamID != "":
url = fmt.Sprintf("https://app.slack.com/app-settings/%s/%s/app-manifest", app.TeamID, app.AppID)
case app.AppID != "":
url = fmt.Sprintf("https://api.slack.com/apps/%s", app.AppID)
}
command := style.Commandf(fmt.Sprintf("manifest --source %s", config.ManifestSourceLocal.String()), false)
remediation := []string{
fmt.Sprintf("Check %s values with %s", config.ManifestSourceLocal.String(), command),
fmt.Sprintf("Compare app settings: %s", style.LinkText(url)),
}
if forceOption {
option := fmt.Sprintf("Write %s manifest values to app settings using `%s`",
config.ManifestSourceLocal.String(),
style.CommandText("--force"),
)
remediation = append(remediation, option)
}
return slackerror.New(slackerror.ErrAppManifestUpdate).WithRemediation("%s", strings.Join(remediation, "\n"))
return false, nil
}

// Displays warning message and details and then gives user a prompt on if they want to continue or not. Returns false
Expand Down
Loading
Loading