diff --git a/cmd/project/create.go b/cmd/project/create.go index 18f66d1d..1a116515 100644 --- a/cmd/project/create.go +++ b/cmd/project/create.go @@ -24,7 +24,9 @@ import ( "time" "github.com/slackapi/slack-cli/cmd/app" + "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/manifest" "github.com/slackapi/slack-cli/internal/pkg/create" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/shared/types" @@ -238,12 +240,61 @@ func runCreateCommand(clients *shared.ClientFactory, cmd *cobra.Command, args [] Text: "App", Secondary: app.FormatListSuccess([]types.App{*linkedApp}), })) + + // Fetch remote manifest and write it to the local project. + // Skip for remote-source projects where manifest.json is not used. + manifestSource, _ := clients.Config.ProjectConfig.GetManifestSource(ctx) + if !manifestSource.Equals(config.ManifestSourceRemote) { + syncRemedy := " Run %s to sync manually" + syncRemedyCmd := style.Commandf("manifest sync --manifest-source=remote", false) + if auth, err := clients.Auth().AuthWithTeamID(ctx, linkedApp.TeamID); err != nil { + clients.IO.PrintWarning(ctx, "Failed to resolve auth for manifest sync: %s", err) + clients.IO.PrintInfo(ctx, false, syncRemedy, syncRemedyCmd) + } else if remoteManifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, auth.Token, linkedApp.AppID); err != nil { + clients.IO.PrintWarning(ctx, "Failed to fetch manifest from app settings: %s", err) + clients.IO.PrintInfo(ctx, false, syncRemedy, syncRemedyCmd) + } else { + appManifest := remoteManifest.AppManifest + if linkedApp.IsDev { + appManifest = manifest.StripDevLocalSuffix(appManifest) + } + writeResult, err := manifest.WriteManifestLocal(clients.Fs, absProjectPath, appManifest) + if err != nil { + clients.IO.PrintWarning(ctx, "Failed to write manifest to project: %s", err) + clients.IO.PrintInfo(ctx, false, syncRemedy, syncRemedyCmd) + } else if writeResult.Written { + lines := []string{fmt.Sprintf("%s Updated manifest.json from app settings", style.Green("✓"))} + if writeResult.Warning != "" { + lines = append(lines, fmt.Sprintf("%s %s", style.Yellow("!"), writeResult.Warning)) + } + printCreateManifestSection(ctx, clients, lines...) + // Hash the remote manifest as exported, since install compares against the export. + hash, err := clients.Config.ProjectConfig.Cache().NewManifestHash(ctx, remoteManifest.AppManifest) + if err == nil { + _ = clients.Config.ProjectConfig.Cache().SetManifestHash(ctx, linkedApp.AppID, hash) + } + } else { + printCreateManifestSection(ctx, clients, + "Skipped copying app settings to the project: no manifest.json found in the project root", + "Your project manifest is managed elsewhere (e.g. manifest.ts), so no changes were made", + ) + } + } + } } printCreateSuccess(ctx, clients, appDirPath) return nil } +func printCreateManifestSection(ctx context.Context, clients *shared.ClientFactory, lines ...string) { + clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "App Manifest", + Secondary: lines, + })) +} + // printCreateSuccess outputs an informative message after creating a new app func printCreateSuccess(ctx context.Context, clients *shared.ClientFactory, appPath string) { // Check if this is a Deno project to conditionally enable some features diff --git a/cmd/project/create_test.go b/cmd/project/create_test.go index e0c4b7b4..a81bc30b 100644 --- a/cmd/project/create_test.go +++ b/cmd/project/create_test.go @@ -17,6 +17,7 @@ package project import ( "context" "fmt" + "path/filepath" "testing" "github.com/slackapi/slack-cli/internal/api" @@ -29,6 +30,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackdeps" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -586,6 +588,26 @@ func TestCreateCommand(t *testing.T) { cm.IO.AssertNotCalled(t, "InputPrompt", mock.Anything, "Name your app:", mock.Anything) }, }, + "without app flag does not sync the manifest or print the app manifest section": { + CmdArgs: []string{"my-project", "--template", "slack-samples/bolt-js-starter-template"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + manifestMock := &app.ManifestMockObject{} + cf.AppClient().Manifest = manifestMock + createManifestMock = manifestMock + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + createManifestMock.AssertNotCalled(t, "GetManifestRemote", mock.Anything, mock.Anything, mock.Anything) + cm.Auth.AssertNotCalled(t, "AuthWithTeamID", mock.Anything, mock.Anything) + assert.NotContains(t, cm.GetCombinedOutput(), "App Manifest") + }, + }, "subdir without template flag returns error": { CmdArgs: []string{"--subdir", "apps/my-app"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { @@ -927,7 +949,7 @@ func TestCreateCommand_AppFlag(t *testing.T) { cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) cm.AddDefaultMocks() - setupCreateLinkMocks(t, ctx, cm, cf) + setupCreateLinkMocks(t, ctx, cm, cf, nil) cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). @@ -957,7 +979,7 @@ func TestCreateCommand_AppFlag(t *testing.T) { cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) cm.AddDefaultMocks() - setupCreateLinkMocks(t, ctx, cm, cf) + setupCreateLinkMocks(t, ctx, cm, cf, nil) cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). @@ -978,6 +1000,240 @@ func TestCreateCommand_AppFlag(t *testing.T) { assert.True(t, saved.IsDev) }, }, + "app flag with manifest fetch error shows warning but succeeds": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceLocal, + manifestRemoteErr: fmt.Errorf("network error"), + }) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + cm.IO.AssertCalled(t, "PrintWarning", mock.Anything, "Failed to fetch manifest from app settings: %s", mock.Anything) + assert.Contains(t, cm.GetCombinedOutput(), "manifest sync --manifest-source=remote") + assert.NotContains(t, cm.GetCombinedOutput(), "App Manifest") + }, + }, + "app flag skips manifest sync for remote source of truth projects": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + projectDir := t.TempDir() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + + original := []byte(`{"display_information": {"name": "Original"}}`) + require.NoError(t, afero.WriteFile(cm.Fs, filepath.Join(projectDir, "manifest.json"), original, 0644)) + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceRemote, + remoteManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Remote"}, + }, + }, + }) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + + createManifestMock.AssertNotCalled(t, "GetManifestRemote", mock.Anything, mock.Anything, mock.Anything) + projectDir := createClientMock.Calls[0].ReturnArguments[0].(string) + written, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) + require.NoError(t, err) + assert.Contains(t, string(written), "Original") + output := cm.GetCombinedOutput() + assert.NotContains(t, output, "App Manifest") + assert.NotContains(t, output, "manifest sync --manifest-source=remote") + hash, err := cm.Config.ProjectConfig.Cache().GetManifestHash(ctx, "A0123456789") + require.NoError(t, err) + assert.Empty(t, hash) + }, + }, + "app flag does not strip local suffix for deployed apps": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "deployed"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + projectDir := t.TempDir() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + + manifestJSON := []byte(`{"display_information": {"name": "Original"}}`) + require.NoError(t, afero.WriteFile(cm.Fs, filepath.Join(projectDir, "manifest.json"), manifestJSON, 0644)) + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceLocal, + remoteManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + }, + }, + }) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "deployed"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + saved, err := cm.AppClient.GetDeployed(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.False(t, saved.IsDev) + + projectDir := createClientMock.Calls[0].ReturnArguments[0].(string) + written, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) + require.NoError(t, err) + assert.Contains(t, string(written), "My App (local)") + }, + }, + "app flag writes manifest and strips local suffix for dev apps": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + projectDir := t.TempDir() + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(projectDir, nil) + CreateFunc = createClientMock.Create + + // Seed a manifest.json so WriteManifestLocal can write to it + manifestJSON := []byte(`{"display_information": {"name": "My App"}}`) + require.NoError(t, afero.WriteFile(cm.Fs, filepath.Join(projectDir, "manifest.json"), manifestJSON, 0644)) + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceLocal, + remoteManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + Features: &types.AppFeatures{BotUser: types.BotUser{DisplayName: "My App (local)"}}, + }, + }, + }) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + + // Verify the written manifest has the (local) suffix stripped + projectDir := createClientMock.Calls[0].ReturnArguments[0].(string) + written, err := afero.ReadFile(cm.Fs, filepath.Join(projectDir, "manifest.json")) + require.NoError(t, err) + assert.Contains(t, string(written), `"My App"`) + assert.NotContains(t, string(written), "(local)") + assert.Contains(t, cm.GetCombinedOutput(), "App Manifest") + assert.Contains(t, cm.GetCombinedOutput(), "Updated manifest.json from app settings") + + // The cached hash must match the manifest as exported (with the suffix), + // which is what install compares against. + exported := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + Features: &types.AppFeatures{BotUser: types.BotUser{DisplayName: "My App (local)"}}, + } + want, err := cm.Config.ProjectConfig.Cache().NewManifestHash(ctx, exported) + require.NoError(t, err) + got, err := cm.Config.ProjectConfig.Cache().GetManifestHash(ctx, "A0123456789") + require.NoError(t, err) + assert.Equal(t, want, got) + }, + }, + "app flag with no manifest.json shows warning": { + CmdArgs: []string{"my-app", "--template", "slack-samples/bolt-js-starter-template", "--app", "A0123456789", "--environment", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + createClientMock = new(CreateClientMock) + createClientMock.On("Create", mock.Anything, mock.Anything, mock.Anything).Return(t.TempDir(), nil) + CreateFunc = createClientMock.Create + + cm.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{mockCreateLinkAuth}, nil) + cm.AddDefaultMocks() + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceLocal, + remoteManifest: types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App"}, + }, + }, + }) + cm.IO.On("SelectPrompt", mock.Anything, "Select a category:", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "slack-samples/bolt-js-starter-template"}, nil).Maybe() + cm.IO.On("SelectPrompt", mock.Anything, "Select the existing app team", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: mockCreateLinkAuth.TeamDomain}, nil) + cm.IO.On("InputPrompt", mock.Anything, "Enter the existing app ID", mock.Anything). + Return("A0123456789", nil) + cm.IO.On("SelectPrompt", mock.Anything, "Choose the app environment", mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Prompt: true, Option: "local"}, nil) + cm.API.On("GetAppStatus", mock.Anything, mockCreateLinkAuth.Token, []string{"A0123456789"}, mockCreateLinkAuth.TeamID). + Return(api.GetAppStatusResult{}, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + createClientMock.AssertCalled(t, "Create", mock.Anything, mock.Anything, mock.Anything) + saved, err := cm.AppClient.GetLocal(ctx, mockCreateLinkAuth.TeamID) + require.NoError(t, err) + assert.Equal(t, "A0123456789", saved.AppID) + cm.IO.AssertNotCalled(t, "PrintWarning", mock.Anything, "Failed to write manifest to project: %s", mock.Anything) + output := cm.GetCombinedOutput() + assert.Contains(t, output, "App Manifest") + assert.Contains(t, output, "no manifest.json found in the project root") + assert.Contains(t, output, "manifest.ts") + assert.NotContains(t, output, "Updated manifest.json") + hash, err := cm.Config.ProjectConfig.Cache().GetManifestHash(ctx, "A0123456789") + require.NoError(t, err) + assert.Empty(t, hash) + }, + }, }, func(cf *shared.ClientFactory) *cobra.Command { return NewCreateCommand(cf) }) @@ -991,9 +1247,22 @@ var mockCreateLinkAuth = types.SlackAuth{ UserID: "U001", } +type createLinkMockOpts struct { + manifestSource config.ManifestSource + manifestRemoteErr error + remoteManifest types.SlackYaml +} + // setupCreateLinkMocks prepares the in-memory project config and manifest mocks // needed by app.LinkExistingApp when called from the create command. -func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { +func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory, manifestRemoteErr error) { + setupCreateLinkMocksWithOpts(t, ctx, cm, cf, createLinkMockOpts{ + manifestSource: config.ManifestSourceRemote, + manifestRemoteErr: manifestRemoteErr, + }) +} + +func setupCreateLinkMocksWithOpts(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory, opts createLinkMockOpts) { projectDirPath := slackdeps.MockWorkingDirectory cm.Os.On("Getwd").Return(projectDirPath, nil) @@ -1003,12 +1272,20 @@ func setupCreateLinkMocks(t *testing.T, ctx context.Context, cm *shared.ClientsM if _, err := config.CreateProjectHooksJSONFile(cm.Fs, projectDirPath, []byte("{}")); err != nil { require.FailNow(t, fmt.Sprintf("Failed to create the hooks file: %s", err)) } - if err := config.SetManifestSource(ctx, cm.Fs, cm.Os, config.ManifestSourceRemote); err != nil { + if err := config.SetManifestSource(ctx, cm.Fs, cm.Os, opts.manifestSource); err != nil { require.FailNow(t, fmt.Sprintf("Failed to set the manifest source: %s", err)) } + cm.Auth.On("AuthWithTeamID", mock.Anything, mock.Anything). + Return(mockCreateLinkAuth, nil) + manifestMock := &app.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything). Return(types.SlackYaml{}, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything). + Return(opts.remoteManifest, opts.manifestRemoteErr) cf.AppClient().Manifest = manifestMock + createManifestMock = manifestMock } + +var createManifestMock *app.ManifestMockObject diff --git a/internal/manifest/diff.go b/internal/manifest/diff.go index e8d0dc78..67b7e436 100644 --- a/internal/manifest/diff.go +++ b/internal/manifest/diff.go @@ -108,6 +108,19 @@ func isIgnoredPath(path string) bool { return false } +// StripDevLocalSuffix removes the " (local)" suffix that Slack's +// apps.manifest.export appends to name fields for dev-installed apps. +func StripDevLocalSuffix(m types.AppManifest) types.AppManifest { + m.DisplayInformation.Name = strings.TrimSuffix(m.DisplayInformation.Name, devLocalSuffix) + if m.Features != nil { + // Copy so the caller's manifest, which shares this pointer, is not modified. + features := *m.Features + features.BotUser.DisplayName = strings.TrimSuffix(features.BotUser.DisplayName, devLocalSuffix) + m.Features = &features + } + return m +} + // isDevLocalSuffixDiff reports whether a Modified diff is purely the result // of Slack's apps.manifest.export appending " (local)" to a name field for a // dev-installed app. Real renames are not suppressed because trimming the diff --git a/internal/manifest/diff_test.go b/internal/manifest/diff_test.go index 37e1ce22..2891792b 100644 --- a/internal/manifest/diff_test.go +++ b/internal/manifest/diff_test.go @@ -274,6 +274,47 @@ func Test_Diff_SurfacesRealIsMcpEnabledDisagreement(t *testing.T) { func ptrBool(b bool) *bool { return &b } +func Test_StripDevLocalSuffix(t *testing.T) { + t.Run("strips suffix from display name and bot user", func(t *testing.T) { + m := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + Features: &types.AppFeatures{BotUser: types.BotUser{DisplayName: "My App (local)"}}, + } + result := StripDevLocalSuffix(m) + assert.Equal(t, "My App", result.DisplayInformation.Name) + assert.Equal(t, "My App", result.Features.BotUser.DisplayName) + }) + + t.Run("no-op when suffix is absent", func(t *testing.T) { + m := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App"}, + Features: &types.AppFeatures{BotUser: types.BotUser{DisplayName: "My App"}}, + } + result := StripDevLocalSuffix(m) + assert.Equal(t, "My App", result.DisplayInformation.Name) + assert.Equal(t, "My App", result.Features.BotUser.DisplayName) + }) + + t.Run("does not modify the input manifest", func(t *testing.T) { + m := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + Features: &types.AppFeatures{BotUser: types.BotUser{DisplayName: "My App (local)"}}, + } + _ = StripDevLocalSuffix(m) + assert.Equal(t, "My App (local)", m.DisplayInformation.Name) + assert.Equal(t, "My App (local)", m.Features.BotUser.DisplayName) + }) + + t.Run("handles nil features", func(t *testing.T) { + m := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "My App (local)"}, + } + result := StripDevLocalSuffix(m) + assert.Equal(t, "My App", result.DisplayInformation.Name) + assert.Nil(t, result.Features) + }) +} + func Test_DiffResult_HasDifferences(t *testing.T) { t.Run("empty result has no differences", func(t *testing.T) { result := &DiffResult{}